From 6d9ac4cf6175f0fff9fa285f83dafc78ae716a8a Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Sat, 29 Aug 2026 11:46:17 +0800 Subject: [PATCH 01/64] feat(scene): add LightFX baking services --- docs/dev/scene/lightfx-bake.md | 674 ++++++++++++++++++ package-lock.json | 425 +++++++++++ package.json | 1 + src/api/scene/lightfx-bake-schema.ts | 51 ++ src/api/scene/lightfx-bake.ts | 58 ++ src/api/scene/scene.ts | 3 + src/core/scene/common/index.ts | 1 + src/core/scene/common/lightfx-bake.ts | 68 ++ src/core/scene/main-process/index.ts | 3 + .../main-process/proxy/lightfx-bake-proxy.ts | 14 + .../baking/lightfx/asset-transaction.ts | 29 + .../service/baking/lightfx/baker.ts | 29 + .../service/baking/lightfx/buffer.ts | 31 + .../service/baking/lightfx/exporter.ts | 101 +++ .../service/baking/lightfx/format.ts | 36 + .../service/baking/lightfx/process.ts | 73 ++ .../service/baking/lightfx/settings.ts | 25 + .../service/baking/lightfx/types.ts | 25 + src/core/scene/scene-process/service/index.ts | 2 + .../scene/scene-process/service/interfaces.ts | 8 + .../scene-process/service/light-probe-bake.ts | 147 ++++ .../scene-process/service/lightmap-bake.ts | 53 ++ .../test/lightfx-asset-transaction.test.ts | 32 + src/core/scene/test/lightfx-format.test.ts | 34 + tests/lightfx-bake-api.test.ts | 16 + 25 files changed, 1939 insertions(+) create mode 100644 docs/dev/scene/lightfx-bake.md create mode 100644 src/api/scene/lightfx-bake-schema.ts create mode 100644 src/api/scene/lightfx-bake.ts create mode 100644 src/core/scene/common/lightfx-bake.ts create mode 100644 src/core/scene/main-process/proxy/lightfx-bake-proxy.ts create mode 100644 src/core/scene/scene-process/service/baking/lightfx/asset-transaction.ts create mode 100644 src/core/scene/scene-process/service/baking/lightfx/baker.ts create mode 100644 src/core/scene/scene-process/service/baking/lightfx/buffer.ts create mode 100644 src/core/scene/scene-process/service/baking/lightfx/exporter.ts create mode 100644 src/core/scene/scene-process/service/baking/lightfx/format.ts create mode 100644 src/core/scene/scene-process/service/baking/lightfx/process.ts create mode 100644 src/core/scene/scene-process/service/baking/lightfx/settings.ts create mode 100644 src/core/scene/scene-process/service/baking/lightfx/types.ts create mode 100644 src/core/scene/scene-process/service/light-probe-bake.ts create mode 100644 src/core/scene/scene-process/service/lightmap-bake.ts create mode 100644 src/core/scene/test/lightfx-asset-transaction.test.ts create mode 100644 src/core/scene/test/lightfx-format.test.ts create mode 100644 tests/lightfx-bake-api.test.ts diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md new file mode 100644 index 000000000..7f2bd31fe --- /dev/null +++ b/docs/dev/scene/lightfx-bake.md @@ -0,0 +1,674 @@ +# LightFX 烘焙能力接入设计 + +## 1. 背景与目标 + +Cocos CLI 需要接入以下两种离线烘焙能力: + +- Light Probe Bake:计算场景中光照探针的球谐光照系数(SH coefficients),将结果写回场景全局数据。 +- Lightmap Bake:生成场景静态模型和地形使用的 Lightmap 纹理,导入 Asset DB 后绑定到对应组件。 + +两种能力都使用 Creator 的 LightFX 工具,并共享场景导出、二进制协议、外部进程管理和结果解析。实现采用“一套公共 LightFX 内核、两个独立业务服务和两个独立 MCP 工具”的结构。 + +本设计的目标是: + +1. 第一阶段完成 Light Probe Bake 时即建立可供 Lightmap 复用的基础设施。 +2. 两种烘焙可以独立调用、独立失败和独立回滚。 +3. 不直接复制 Creator 中同时混合面板、Metrics、Lightmap 和 Light Probe 的大文件。 +4. 保持烘焙输入、LightFX 协议和结果应用逻辑与 Creator 兼容。 +5. 支持 MCP、CLI 场景服务以及未来 VSCode/Pink 场景编辑器调用。 + +非目标: + +- 第一阶段不提供一个同时烘焙 Light Probe 和 Lightmap 的公开 `both` 接口。 +- MCP 可以可选覆盖真正参与计算的烘焙参数;未传参数时读取场景或项目现有配置。编辑器可视化参数不混入 Bake 接口。 +- 不实现新的 LightFX 算法,也不修改引擎的光照探针或 Lightmap 数据结构。 +- 不要求浏览器 `/scene-editor/` 提供 WebGL 捕获能力。 + +## 2. Creator 现有流程 + +### 2.1 Light Probe + +Creator 的调用链为: + +```text +Light Probe 面板 + -> 获取 Lightmap 配置并设置 temp/light-probe 输出目录 + -> lightmap 扩展的 bakeLightProbe + -> scene process 中导出场景 + -> 写入 tmp/lfx.in + -> 启动本地 Socket.IO 服务和 LightFX 进程 + -> LightFX 输出 output/lfx.out + -> 解析 Position、Normal、SH coefficients + -> 写回 scene.globals.lightProbeInfo.data.probes + -> lightProbeInfo.onProbeBakeFinished() + -> repaint、记录场景修改 +``` + +相关 Creator 代码: + +- `app/modules/editor-extensions/extensions/light-probe/source/renderer.ts` +- `app/modules/editor-extensions/extensions/lightmap/source/lightmap/index.ts` +- `app/modules/editor-extensions/extensions/lightmap/source/lightmap/backer/LFX_App.ts` +- `app/modules/editor-extensions/extensions/lightmap/source/lightmap/backer/LFX_Baker.ts` +- `app/modules/editor-extensions/extensions/lightmap/source/lightmap/backer/LFX_Types.ts` + +### 2.2 Lightmap + +Lightmap 与 Light Probe 使用相同的场景导出和 LightFX 进程。区别在于结果包含纹理以及模型、地形对应的 UV offset/scale。Creator 在完成后还会: + +- 刷新并导入输出 PNG。 +- 修改图片 meta,使其作为纹理导入。 +- 加载 Texture2D 子资源。 +- 调用 MeshRenderer/Terrain 的 Lightmap 更新接口。 +- 更新 `bakedWithStationaryMainLight`、`bakedWithHighpLightmap` 等场景全局状态。 + +### 2.3 引擎数据 + +Light Probe 配置和结果位于: + +```text +scene.globals.lightProbeInfo + giScale + giSamples + bounces + reduceRinging + data.probes[] + position + normal + coefficients[] + data.tetrahedrons[] +``` + +`LightProbeGroup` 负责生成局部探针位置,并通过 `LightProbeInfo.syncData()`、`update()` 汇总世界坐标及更新四面体。烘焙完成后应调用 `onProbeBakeFinished()`,通知使用探针的模型刷新。 + +引擎参考代码: + +- `resources/3d/engine/cocos/gi/light-probe/light-probe-group.ts` +- `resources/3d/engine/cocos/gi/light-probe/light-probe.ts` +- `resources/3d/engine/cocos/scene-graph/scene-globals.ts` + +## 3. 总体架构 + +```text +MCP/API + |-- scene-bake-light-probes + `-- scene-bake-lightmap + | + v +scene-process business services + |-- LightProbeBakeService -- 写回 SH、通知引擎、Undo、保存场景 + `-- LightmapBakeService -- 导入纹理、绑定组件、Undo、保存场景 + | + v +shared LightFX baking core + |-- scene exporter + |-- texture resolver + |-- lfx.in/out codec + |-- LightFX process and Socket.IO lifecycle + `-- workspace and cleanup + | + v +static/tools/lightmap-tools/LightFX(.exe) +``` + +公共内核只产生结构化烘焙结果,不直接修改场景或 Asset DB。结果提交和回滚由业务服务负责。 + +建议目录: + +```text +src/core/scene/scene-process/service/baking/lightfx/ + types.ts + buffer.ts + format.ts + exporter.ts + texture-resolver.ts + process.ts + workspace.ts + baker.ts + +src/core/scene/scene-process/service/light-probe.ts +src/core/scene/scene-process/service/lightmap.ts +``` + +公共类型和 API: + +```text +src/core/scene/common/light-probe.ts +src/core/scene/common/lightmap.ts +src/api/scene/light-probe-schema.ts +src/api/scene/light-probe.ts +src/api/scene/lightmap-schema.ts +src/api/scene/lightmap.ts +``` + +## 4. 公共 LightFX 内核 + +### 4.1 烘焙目标 + +内核内部支持目标枚举: + +```ts +type LightFXBakeTarget = 'light-probe' | 'lightmap'; +``` + +内部数据结构可以为未来组合执行保留两个布尔位,但第一阶段不公开 `both`,也不让一个业务服务同时提交两类结果。 + +### 4.2 场景导出 + +导出器从当前 scene-process 中的真实引擎对象读取数据,至少覆盖: + +- 场景名称和全局烘焙配置。 +- 非 `Movable` 的有效节点。 +- MeshRenderer/SkinnedMeshRenderer 所需网格数据。 +- Terrain 数据。 +- DirectionalLight、SphereLight、SpotLight。 +- 材质的 diffuse、emissive、metallic、roughness、alpha cutoff 及相关贴图。 +- Light Probe 的世界坐标和法线。 + +过滤规则必须与 Creator 对齐: + +- Light Probe Bake 只导出 `bakeSettings.bakeToLightProbe` 为真的模型。 +- Lightmap Bake 根据 `bakeable`、`castShadow` 和 `receiveShadow` 决定导出和接收行为。 +- inactive 节点、不可用组件和 `Movable` 节点不参与静态烘焙。 +- HDR 与非 HDR 下的光强换算保持 Creator 行为。 + +导出器不能访问 MCP、Undo 或场景保存服务,使其可以用构造的场景对象单独测试。 + +### 4.3 纹理解析 + +材质可能引用普通资源 UUID或带子资源后缀的 UUID。纹理解析器通过主进程 Asset DB RPC: + +- 普通 UUID:查询真实文件路径。 +- library 子资源:定位项目 `library//` 文件。 +- 缺失资源:记录明确的资源和材质信息;必要贴图缺失时失败,可选贴图可降级为空。 + +纹理文件复制到本次烘焙 workspace,文件名必须稳定并避免不同目录同名冲突。 + +### 4.4 二进制协议 + +`format.ts` 和 `buffer.ts` 负责 Creator/LightFX 使用的 `lfx.in`、`lfx.out` 协议,包括: + +- 文件版本和 chunk ID。 +- Settings。 +- Terrain、Mesh、Material、Light、LightProbe 输入。 +- Terrain/Mesh Lightmap 信息和 LightProbe 输出。 +- 数组长度、字符串、整数和浮点数的边界检查。 + +解析输出时必须拒绝: + +- 不支持的版本。 +- 未知或截断的 chunk。 +- 非有限浮点数。 +- 负数或异常大的数组长度。 +- 探针、模型或地形索引越界。 + +### 4.5 Workspace + +每次烘焙使用唯一工作目录,不能复用 Creator 固定的 `temp/light-probe`: + +```text +/temp/lightfx-bake// + tmp/lfx.in + tmp/ + output/lfx.out + output/ +``` + +规则: + +- 成功后默认清理临时输入;Lightmap 输出完成资产提交后再清理。 +- 失败、取消和超时均执行 finally 清理。 +- 可增加内部 `keepTemporaryFiles` 调试开关,但不作为首版 MCP 参数。 +- 不删除 operation-id 目录以外的任何文件。 + +### 4.6 LightFX 进程生命周期 + +进程层负责: + +1. 从 `GlobalPaths.staticDir/tools/lightmap-tools` 定位平台可执行文件。 +2. 创建本地 Socket.IO 服务并监听随机端口。 +3. 启动 LightFX,将本地 URL 作为参数传入。 +4. 等待 Login,发送 Start,接收 Log、Progress、Finished。 +5. Finished 后读取完整 `lfx.out`,再发送 Stop。 +6. 关闭 Socket.IO 服务并终止子进程。 + +必须处理: + +- 工具不存在或没有执行权限。 +- 端口创建失败。 +- Login 超时。 +- LightFX 非零退出或异常退出。 +- 输出文件缺失、尚未写完或解析失败。 +- 用户取消和总流程超时。 +- 服务或进程只能完成一次清理,避免重复 resolve/reject。 + +公共内核同一时间默认只允许一个 LightFX 烘焙任务,避免多个进程争用 CPU、端口或项目资源。 + +### 4.7 进度事件 + +公共进度阶段: + +```ts +type LightFXBakeStage = + | 'validating' + | 'exporting-scene' + | 'resolving-textures' + | 'starting-baker' + | 'baking' + | 'reading-result' + | 'applying-result' + | 'saving-scene' + | 'completed'; +``` + +业务服务可广播内部进度事件。MCP 首版仍等待最终结果,不依赖 Inspector 对通知的展示能力。 + +## 5. Light Probe Bake + +### 5.1 MCP 接口 + +工具名: + +```text +scene-bake-light-probes +``` + +建议参数: + +```ts +interface ILightProbeBakeOptions { + giScale?: number; + giSamples?: number; + bounces?: number; + saveScene?: boolean; + timeoutMs?: number; +} +``` + +- `saveScene` 默认 `true`。 +- `timeoutMs` 覆盖完整流程,默认建议 600 秒,并设置合理最大值。 +- `giScale`、`giSamples`、`bounces` 真正参与 LightFX 计算,允许 MCP 对本次烘焙进行可选覆盖。 +- 未传入覆盖值时,从 `scene.globals.lightProbeInfo` 读取当前值。 +- 覆盖值默认只作用于本次烘焙,不修改 `LightProbeInfo` 的持久化配置;如需永久修改,应通过场景属性编辑接口完成。 +- 参数校验与引擎约束一致:`giScale` 为 `[0, 100]` 的有限数,`giSamples` 为 `[64, 65535]` 的整数,`bounces` 为 `[1, 4]` 的整数。 +- 烘焙范围为当前打开场景中的全部有效 LightProbeGroup,而不是某个 `nodePath`。 + +以下 `LightProbeInfo` 参数不进入 Bake 接口: + +- `reduceRinging`:运行时对 SH 系数的振铃抑制参数,不参与 LightFX 烘焙计算。 +- `showProbe`、`showWireframe`、`showConvex`:编辑器可视化开关。 +- `lightProbeSphereVolume`:编辑器中的探针显示尺寸。 + +这些参数仍保留在场景中,烘焙不会覆盖它们。 + +MCP JSON 示例: + +```json +{ + "options": { + "giScale": 8, + "giSamples": 4096, + "bounces": 1, + "saveScene": true, + "timeoutMs": 600000 + } +} +``` + +返回值: + +```ts +interface ILightProbeBakeResult { + sceneUrl: string; + probeCount: number; + giScale: number; + giSamples: number; + bounces: number; + durationMs: number; +} +``` + +### 5.2 前置校验 + +- 当前打开的是具有 Asset URL 的场景,不支持 prefab。 +- `lightProbeInfo.data` 存在。 +- 至少存在 4 个有效探针,并已建立四面体数据。 +- 所有 position、normal 和配置值均为有限数。 +- 当前没有其他 LightFX 烘焙任务。 +- LightFX 工具存在并可启动。 + +若用户只添加了 LightProbeGroup 但没有生成探针,应返回明确提示,而不是输出空结果。 + +### 5.3 结果校验与提交 + +提交前校验: + +- 输出探针数量与请求输入一致。 +- 探针顺序与输入一致;位置应在允许误差内匹配。 +- 每个探针的 SH coefficient 数量符合引擎 `SH.getBasisCount()`。 +- 所有系数均为有限数。 + +提交时序: + +```text +保存旧 coefficients + -> begin Undo recording + -> 一次性写入全部 coefficients/normal + -> lightProbeInfo.onProbeBakeFinished() + -> Engine.repaintInEditMode() + -> 按需保存场景 + -> end Undo recording +``` + +若提交或保存失败: + +- cancel Undo recording。 +- 恢复旧 coefficients 和 normal。 +- 再次通知引擎并重绘。 +- 返回失败,不留下部分探针的新数据。 + +Light Probe 结果直接序列化在 `.scene` 中,不创建新的 Asset DB 资源。 + +### 5.4 清除接口 + +清除烘焙结果可作为后续独立工具: + +```text +scene-clear-light-probes +``` + +其语义应调用 `lightProbeInfo.onProbeBakeCleared()`,进入 Undo,并按需保存场景。首个 Bake PR 不必同时实现。 + +## 6. Lightmap Bake + +### 6.1 MCP 接口 + +工具名: + +```text +scene-bake-lightmap +``` + +建议参数: + +```ts +interface ILightmapBakeOptions { + msaa?: 1 | 2 | 4 | 8; + resolution?: number; + filter?: boolean; + highp?: boolean; + giScale?: number; + giSamples?: number; + giPathLength?: number; + aoLevel?: number; + aoStrength?: number; + aoRadius?: number; + aoColor?: [number, number, number, number?]; + threads?: number; + saveScene?: boolean; + timeoutMs?: number; + outputDir?: string; +} +``` + +- `msaa`、`resolution`、`filter`、`highp`、GI、AO 和 `threads` 都会影响 LightFX 计算,允许 MCP 对本次烘焙进行可选覆盖。 +- 未传入的参数读取项目现有 Lightmap 配置;项目配置也不存在时才使用与 Creator 一致的默认值。 +- MCP 覆盖值默认只作用于本次烘焙,不写回项目 Lightmap 配置。永久修改配置应使用独立配置接口。 +- 第一版可以暂不开放 `outputDir`,统一输出到场景对应目录;若开放,只接受 `db://assets` 下的目录。 +- 不允许传入任意绝对输出路径。 + +面板参数映射: + +| Creator 面板 | MCP 参数 | LightFX 字段 | +| --- | --- | --- | +| 多重采样抗锯齿 | `msaa` | `MSAA` | +| 烘焙分辨率 | `resolution` | `Size` | +| 应用线性过滤 | `filter` | `Filter` | +| 高精度烘焙 | `highp` | `Highp` | +| 全局光照倍数 | `giScale` | `GIScale` | +| 全局光照采样点 | `giSamples` | `GISamples` | +| 光线追踪次数 | `giPathLength` | `GIPathLength` | +| 环境光遮蔽等级 | `aoLevel` | `AOLevel` | +| 环境光遮蔽强度 | `aoStrength` | `AOStrength` | +| 环境光遮蔽半径 | `aoRadius` | `AORadius` | +| 环境光遮蔽颜色 | `aoColor` | `AOColor` | + +MCP JSON 示例: + +```json +{ + "options": { + "msaa": 4, + "resolution": 1024, + "filter": true, + "highp": false, + "giScale": 1, + "giSamples": 25, + "giPathLength": 4, + "aoLevel": 0, + "aoStrength": 0.5, + "aoRadius": 1, + "aoColor": [136, 136, 136, 255], + "saveScene": true, + "timeoutMs": 600000 + } +} +``` + +返回值建议包含: + +```ts +interface ILightmapBakeResult { + sceneUrl: string; + textureUrls: string[]; + meshCount: number; + terrainCount: number; + durationMs: number; +} +``` + +### 6.2 资产输出 + +建议稳定输出到: + +```text +db://assets//lightmap/ +``` + +不能直接让 LightFX 写入最终资产目录。流程应为: + +1. LightFX 写入唯一临时 workspace。 +2. 完整校验 `lfx.out` 和所有引用 PNG。 +3. 将 PNG 和 meta 暂存到最终目录旁的临时名称。 +4. 原子替换最终文件,并保留事务备份。 +5. Asset DB refresh/import。 +6. 等待 Texture2D 子资源可查询和加载。 +7. 绑定模型与地形。 +8. 保存场景后提交文件事务。 + +重烘焙必须尽量复用已有资源 UUID,避免场景引用和版本管理中持续产生新资产。 + +### 6.3 图片导入 + +图片 meta 至少保证: + +- 作为 Texture2D 导入。 +- `fixAlphaTransparencyArtifacts=false`,与 Creator 行为一致。 +- 高精度、颜色空间、filter、wrap 等选项由 Lightmap 输出规范明确设置,不能依赖导入器偶然默认值。 + +Asset DB refresh 后必须轮询目标 Texture2D 是否真正可加载,不能只等待文件事件。 + +### 6.4 结果绑定 + +根据 `lfx.out` 中稳定的导出索引绑定: + +- MeshRenderer:纹理、offset.x/y、scale.x/y。 +- Terrain block:纹理、block id、offset 和 scale。 +- Stationary 主灯及高精度 Lightmap 对应的场景全局标志。 + +导出阶段必须建立 `export index -> engine object/component UUID` 映射,禁止在结果阶段重新按场景遍历顺序猜测对象。 + +### 6.5 Lightmap 事务 + +Lightmap 同时修改文件资产和场景,事务边界为: + +```text +生成并校验临时结果 + -> 备份/替换最终 PNG 和 meta + -> Asset DB 导入并加载 Texture2D + -> begin Undo recording + -> 绑定全部 Mesh/Terrain 并更新 globals + -> 保存场景 + -> end Undo recording + -> 删除文件备份 +``` + +失败时按相反顺序回滚: + +- 恢复组件原 Lightmap 引用和 globals。 +- cancel Undo recording。 +- 恢复旧 PNG/meta 或删除本次新增文件。 +- 刷新 Asset DB,使内存资源状态与磁盘一致。 + +回滚实现应复用 Reflection Probe Bake 已验证的文件替换事务思想,但抽成通用文件事务后再由 Lightmap 使用。 + +### 6.6 清除接口 + +后续可增加: + +```text +scene-clear-lightmap +``` + +清除应解除组件绑定并更新 globals。是否删除磁盘纹理由显式参数控制,默认只解除绑定,避免破坏被其他场景引用的资源。 + +## 7. 进程与运行环境边界 + +LightFX 烘焙不同于 Reflection Probe 捕获: + +- 不需要 WebGL 六面渲染。 +- 不需要 `/scene-editor/` 保持打开或可见。 +- 不通过 Socket.IO 回传大块 RGBA 数据。 +- 不依赖 MCP Server 的 `maxHttpBufferSize`。 + +烘焙运行在 Node scene-process,文件、Asset DB、配置和工具路径等 Node 能力通过现有 RPC 访问主进程。LightFX 自己使用的本地 Socket.IO 只用于 CLI 与外部烘焙进程通信,不是浏览器场景渲染器通道。 + +因此未来 VSCode/Pink 编辑器只要通过 CLI 打开了可在 scene-process 中完整加载的场景,即可调用这两个 MCP 工具。 + +## 8. 并发、取消和超时 + +- 全局同一时间只允许一个 LightFX 任务。 +- 重复调用立即返回“已有烘焙任务运行中”,不进入等待队列。 +- 业务接口内部使用 operation id,所有事件、workspace 和结果均绑定该 id。 +- 超时覆盖导出、工具启动、烘焙、结果解析、资源导入和场景保存。 +- 取消应同时终止 LightFX、关闭 Socket.IO、停止结果提交并清理 workspace。 +- 一旦进入结果提交阶段,取消按失败处理并执行事务回滚。 + +首版可以只提供内部取消能力;后续再增加公开的 `scene-cancel-lightfx-bake`,同时返回被取消任务的类型和 operation id。 + +## 9. 错误模型 + +错误信息至少区分: + +- 场景未打开或不是场景资产。 +- 没有探针、探针不足或未生成四面体。 +- 没有可烘焙模型/地形。 +- 场景依赖资产缺失。 +- LightFX 工具缺失或不支持当前平台。 +- LightFX 启动、连接、执行或退出失败。 +- 输入/输出协议不兼容或结果损坏。 +- 探针、Mesh、Terrain 结果数量不匹配。 +- Asset DB 导入或 Texture2D 加载超时。 +- 场景保存失败及回滚失败。 + +对用户返回简洁原因;详细子进程 stdout/stderr、阶段和 operation id 写入 CLI 日志。日志不能输出完整二进制数据或大块纹理内容。 + +## 10. 测试方案 + +### 10.1 公共内核单元测试 + +- `lfx.in` 固定 fixture 编码结果与 Creator 兼容。 +- `lfx.out` fixture 能正确解析 Light Probe、Mesh 和 Terrain 结果。 +- 截断、未知版本、非法长度、NaN/Infinity 被拒绝。 +- 场景过滤和导出索引稳定。 +- 纹理 UUID、子资源和缺失资源解析。 +- LightFX 正常结束、异常退出、超时、取消和重复清理。 +- workspace 只清理自身目录。 + +### 10.2 Light Probe 测试 + +- API schema 和 MCP 工具注册。 +- 无场景、无探针、少于 4 个探针。 +- 未传覆盖参数时从场景读取 `giScale/giSamples/bounces`。 +- 覆盖参数只影响本次 LightFX 输入,不意外改写场景配置。 +- `reduceRinging` 和可视化参数不进入烘焙参数。 +- 探针数量或位置不匹配时不写回。 +- 成功时一次性写回 SH、通知引擎并保存。 +- 写回或保存失败时恢复全部旧系数。 +- 重复烘焙和并发调用。 + +### 10.3 Lightmap 测试 + +- Mesh/Terrain 导出和结果索引映射。 +- Lightmap 项目配置默认值、MCP 部分覆盖及参数校验。 +- MCP 覆盖参数只影响本次任务,不意外写回项目配置。 +- PNG/meta 创建、覆盖及 UUID 复用。 +- Asset DB 导入后等待 Texture2D。 +- 绑定 offset/scale 和 globals。 +- 文件替换后导入失败、绑定失败、保存失败的完整回滚。 +- 重复烘焙不残留 backup、staging 或临时目录。 + +### 10.4 端到端验证场景 + +至少准备: + +1. 基础 Mesh、DirectionalLight 和单个 LightProbeGroup。 +2. 多个 LightProbeGroup,验证世界坐标汇总与顺序。 +3. SphereLight、SpotLight、发光材质和纹理材质。 +4. HDR 与非 HDR 场景。 +5. Mesh 与 Terrain 混合的 Lightmap 场景。 +6. 重复烘焙、取消、超时和缺失贴图场景。 + +端到端验证需在重新打开场景后确认: + +- Light Probe SH 数据仍存在,动态模型间接光正确。 +- Lightmap 纹理引用有效,模型和 Terrain 显示正确。 +- 场景和资产目录没有 staging、backup 或失效 meta 残留。 + +## 11. 分阶段交付 + +### 阶段一:公共内核与 Light Probe Bake + +- LightFX 数据类型和二进制协议。 +- Scene exporter 和 texture resolver。 +- LightFX 进程、workspace、超时和取消。 +- Light Probe MCP/Service、SH 回填、Undo 和保存。 +- 协议测试、服务测试和真实场景验证。 + +阶段一验收条件:同一场景在 Creator 与 CLI 烘焙后探针数量、系数结构和运行时光照表现一致;失败与重复烘焙不破坏旧数据。 + +### 阶段二:Lightmap Bake + +- 扩展公共 exporter 的 Lightmap 专用数据。 +- Lightmap 输出解析、图片事务和 Asset DB 导入。 +- MeshRenderer/Terrain 绑定、globals、Undo 和保存。 +- 重烘焙与失败回滚验证。 + +阶段二验收条件:基础 Mesh/Terrain 场景可在 CLI 完整烘焙,重新打开后纹理与组件引用保持有效。 + +### 阶段三:完善能力 + +- 公开取消接口。 +- Light Probe/Lightmap 清除接口。 +- 更多材质、灯光和平台兼容。 +- 根据实际需求评估组合烘焙入口和进度查询接口。 + +## 12. 实现约束与评审重点 + +- 公共内核不得依赖 Creator 的 `Editor.Message`、Panel 或 Metrics。 +- MCP API 不直接操作 LightFX、文件或引擎对象,只调用场景服务。 +- 不把 Lightmap 资产导入逻辑放入公共 exporter。 +- 不在循环内反复创建 Undo snapshot;一次烘焙只形成一个业务操作。 +- 所有外部进程、Socket.IO 服务和临时目录必须有确定的 finally 清理路径。 +- 所有最终文件替换必须可回滚,不能先删除旧资产再尝试导入新资产。 +- 第一阶段新增公共接口时,要用 Lightmap 场景验证其模型索引、纹理解析和结果结构是否足够,避免第二阶段推翻公共层。 diff --git a/package-lock.json b/package-lock.json index 6c266c5e6..26e7809f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,6 +79,7 @@ "sharp": "0.32.6", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", + "socket.io-v2": "npm:socket.io@2.3.0", "strip-ansi": "^6.0.1", "tga-js": "^1.1.1", "tmp": "^0.0.33", @@ -9240,6 +9241,12 @@ "node": ">=0.4.0" } }, + "node_modules/after": { + "version": "0.8.2", + "resolved": "https://registry.npmmirror.com/after/-/after-0.8.2.tgz", + "integrity": "sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA==", + "license": "MIT" + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", @@ -9455,6 +9462,12 @@ "node": ">=0.10.0" } }, + "node_modules/arraybuffer.slice": { + "version": "0.0.7", + "resolved": "https://registry.npmmirror.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz", + "integrity": "sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==", + "license": "MIT" + }, "node_modules/asn1.js": { "version": "4.10.1", "resolved": "https://registry.npmmirror.com/asn1.js/-/asn1.js-4.10.1.tgz", @@ -9560,6 +9573,12 @@ "license": "MIT", "optional": true }, + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "license": "MIT" + }, "node_modules/async-settle": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/async-settle/-/async-settle-2.0.0.tgz", @@ -9942,6 +9961,12 @@ "node": ">=10.13.0" } }, + "node_modules/backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", @@ -10082,6 +10107,14 @@ "node": ">= 0.4" } }, + "node_modules/base64-arraybuffer": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz", + "integrity": "sha512-a1eIFi4R9ySrbiMuyTGx5e92uRH5tQY6kArNcFaKBUleIoLjdjBg7Zxm3Mqm3Kmkf27HLR/1fnxX9q8GQ7Iavg==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", @@ -10130,6 +10163,17 @@ "node": ">=10.0.0" } }, + "node_modules/better-assert": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/better-assert/-/better-assert-1.0.2.tgz", + "integrity": "sha512-bYeph2DFlpK1XmGs6fvlLRUN29QISM3GBuUwSFsMY2XRx4AvC0WNCS57j4c/xGrK2RS24C1w3YoBOsw9fT46tQ==", + "dependencies": { + "callsite": "1.0.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmmirror.com/big.js/-/big.js-5.2.2.tgz", @@ -10216,6 +10260,12 @@ "node": ">= 6" } }, + "node_modules/blob": { + "version": "0.0.5", + "resolved": "https://registry.npmmirror.com/blob/-/blob-0.0.5.tgz", + "integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==", + "license": "MIT" + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.7.2.tgz", @@ -10831,6 +10881,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==", + "engines": { + "node": "*" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", @@ -11351,6 +11409,11 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "license": "MIT" }, + "node_modules/component-bind": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/component-bind/-/component-bind-1.0.0.tgz", + "integrity": "sha512-WZveuKPeKAG9qY+FkYDeADzdHyTYdIboXS59ixDeRJL5ZhxpqUnxSOwop4FQjMsiYm3/Or8cegVbpAHNA7pHxw==" + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmmirror.com/component-emitter/-/component-emitter-1.3.1.tgz", @@ -11360,6 +11423,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/component-inherit": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/component-inherit/-/component-inherit-0.0.3.tgz", + "integrity": "sha512-w+LhYREhatpVqTESyGFg3NlP6Iu0kEKUHETY9GoZP/pQyW4mHFZuFWRUCIqVPZ36ueVLtoOEZaAqbCF2RDndaA==" + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmmirror.com/compressible/-/compressible-2.0.18.tgz", @@ -14627,6 +14695,27 @@ "node": ">= 0.4.0" } }, + "node_modules/has-binary2": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/has-binary2/-/has-binary2-1.0.3.tgz", + "integrity": "sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==", + "license": "MIT", + "dependencies": { + "isarray": "2.0.1" + } + }, + "node_modules/has-binary2/node_modules/isarray": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.1.tgz", + "integrity": "sha512-c2cu3UxbI+b6kR3fy0nRnAhodsvR9dx7U5+znCOzdj6IfP3upFURTr0Xl5BlQZNKZjEtxrmVyfSdeE3O57smoQ==", + "license": "MIT" + }, + "node_modules/has-cors": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-cors/-/has-cors-1.1.0.tgz", + "integrity": "sha512-g5VNKdkFuUuVCP9gYfDJHjK2nqdQJ7aDLTnycnc2+RvsOQbuLdF5pm7vuE5J76SEBIQjs4kQY/BWq74JUmjbXA==", + "license": "MIT" + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz", @@ -15046,6 +15135,11 @@ "node": ">=0.8.19" } }, + "node_modules/indexof": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/indexof/-/indexof-0.0.1.tgz", + "integrity": "sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==" + }, "node_modules/infer-owner": { "version": "1.0.4", "resolved": "https://registry.npmmirror.com/infer-owner/-/infer-owner-1.0.4.tgz", @@ -19855,6 +19949,11 @@ "node": ">=0.10.0" } }, + "node_modules/object-component": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/object-component/-/object-component-0.0.3.tgz", + "integrity": "sha512-S0sN3agnVh2SZNEIGc0N1X4Z5K0JeFbGBrnuZpsxuUh5XLF0BnvWkMjRXo/zGKLd/eghvNIKcx1pQkmUjXIyrA==" + }, "node_modules/object-copy": { "version": "0.1.0", "resolved": "https://registry.npmmirror.com/object-copy/-/object-copy-0.1.0.tgz", @@ -20371,6 +20470,24 @@ "node": ">=0.10.0" } }, + "node_modules/parseqs": { + "version": "0.0.5", + "resolved": "https://registry.npmmirror.com/parseqs/-/parseqs-0.0.5.tgz", + "integrity": "sha512-B3Nrjw2aL7aI4TDujOzfA4NsEc4u1lVcIRE0xesutH8kjeWF70uk+W5cBlIQx04zUH9NTBvuN36Y9xLRPK6Jjw==", + "license": "MIT", + "dependencies": { + "better-assert": "~1.0.0" + } + }, + "node_modules/parseuri": { + "version": "0.0.5", + "resolved": "https://registry.npmmirror.com/parseuri/-/parseuri-0.0.5.tgz", + "integrity": "sha512-ijhdxJu6l5Ru12jF0JvzXVPvsC+VibqeaExlNoMhWN6VQ79PGjkmc7oA4W1lp00sFkNyj0fx6ivPLdV51/UMog==", + "license": "MIT", + "dependencies": { + "better-assert": "~1.0.0" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", @@ -23253,6 +23370,303 @@ } } }, + "node_modules/socket.io-v2": { + "name": "socket.io", + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/socket.io/-/socket.io-2.3.0.tgz", + "integrity": "sha512-2A892lrj0GcgR/9Qk81EaY2gYhCBxurV0PfmmESO6p27QPrUK1J3zdns+5QPqvUYK2q657nSj0guoIil9+7eFg==", + "license": "MIT", + "dependencies": { + "debug": "~4.1.0", + "engine.io": "~3.4.0", + "has-binary2": "~1.0.2", + "socket.io-adapter": "~1.1.0", + "socket.io-client": "2.3.0", + "socket.io-parser": "~3.4.0" + } + }, + "node_modules/socket.io-v2/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io-v2/node_modules/component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha512-jPatnhd33viNplKjqXKRkGU345p263OIWzDL2wH3LGIGp5Kojo+uXizHmOADRvhGFFTnJqX3jBAKP6vvmSDKcA==", + "license": "MIT" + }, + "node_modules/socket.io-v2/node_modules/cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha512-+IJOX0OqlHCszo2mBUq+SrEbCj6w7Kpffqx60zYbPTFaO4+yYgRjHwcZNpWvaTylDHaV7PPmBHzSecZiMhtPgw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io-v2/node_modules/debug": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/socket.io-v2/node_modules/engine.io": { + "version": "3.4.2", + "resolved": "https://registry.npmmirror.com/engine.io/-/engine.io-3.4.2.tgz", + "integrity": "sha512-b4Q85dFkGw+TqgytGPrGgACRUhsdKc9S9ErRAXpPGy/CXKs4tYoHDkvIRdsseAF7NjfVwjRFIn6KTnbw7LwJZg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "0.3.1", + "debug": "~4.1.0", + "engine.io-parser": "~2.2.0", + "ws": "^7.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/socket.io-v2/node_modules/engine.io-client": { + "version": "3.4.4", + "resolved": "https://registry.npmmirror.com/engine.io-client/-/engine.io-client-3.4.4.tgz", + "integrity": "sha512-iU4CRr38Fecj8HoZEnFtm2EiKGbYZcPn3cHxqNGl/tmdWRf60KhK+9vE0JeSjgnlS/0oynEfLgKbT9ALpim0sQ==", + "license": "MIT", + "dependencies": { + "component-emitter": "~1.3.0", + "component-inherit": "0.0.3", + "debug": "~3.1.0", + "engine.io-parser": "~2.2.0", + "has-cors": "1.1.0", + "indexof": "0.0.1", + "parseqs": "0.0.6", + "parseuri": "0.0.6", + "ws": "~6.1.0", + "xmlhttprequest-ssl": "~1.5.4", + "yeast": "0.1.2" + } + }, + "node_modules/socket.io-v2/node_modules/engine.io-client/node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/socket.io-v2/node_modules/engine.io-client/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/socket.io-v2/node_modules/engine.io-client/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/socket.io-v2/node_modules/engine.io-client/node_modules/parseqs": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/parseqs/-/parseqs-0.0.6.tgz", + "integrity": "sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==", + "license": "MIT" + }, + "node_modules/socket.io-v2/node_modules/engine.io-client/node_modules/parseuri": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/parseuri/-/parseuri-0.0.6.tgz", + "integrity": "sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==", + "license": "MIT" + }, + "node_modules/socket.io-v2/node_modules/engine.io-client/node_modules/ws": { + "version": "6.1.4", + "resolved": "https://registry.npmmirror.com/ws/-/ws-6.1.4.tgz", + "integrity": "sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==", + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/socket.io-v2/node_modules/engine.io-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-2.2.1.tgz", + "integrity": "sha512-x+dN/fBH8Ro8TFwJ+rkB2AmuVw9Yu2mockR/p3W8f8YtExwFgDvBDi0GWyb4ZLkpahtDGZgtr3zLovanJghPqg==", + "license": "MIT", + "dependencies": { + "after": "0.8.2", + "arraybuffer.slice": "~0.0.7", + "base64-arraybuffer": "0.1.4", + "blob": "0.0.5", + "has-binary2": "~1.0.2" + } + }, + "node_modules/socket.io-v2/node_modules/isarray": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.1.tgz", + "integrity": "sha512-c2cu3UxbI+b6kR3fy0nRnAhodsvR9dx7U5+znCOzdj6IfP3upFURTr0Xl5BlQZNKZjEtxrmVyfSdeE3O57smoQ==", + "license": "MIT" + }, + "node_modules/socket.io-v2/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io-v2/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io-v2/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io-v2/node_modules/socket.io-adapter": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz", + "integrity": "sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g==", + "license": "MIT" + }, + "node_modules/socket.io-v2/node_modules/socket.io-client": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/socket.io-client/-/socket.io-client-2.3.0.tgz", + "integrity": "sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==", + "license": "MIT", + "dependencies": { + "backo2": "1.0.2", + "base64-arraybuffer": "0.1.5", + "component-bind": "1.0.0", + "component-emitter": "1.2.1", + "debug": "~4.1.0", + "engine.io-client": "~3.4.0", + "has-binary2": "~1.0.2", + "has-cors": "1.1.0", + "indexof": "0.0.1", + "object-component": "0.0.3", + "parseqs": "0.0.5", + "parseuri": "0.0.5", + "socket.io-parser": "~3.3.0", + "to-array": "0.1.4" + } + }, + "node_modules/socket.io-v2/node_modules/socket.io-client/node_modules/base64-arraybuffer": { + "version": "0.1.5", + "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", + "integrity": "sha512-437oANT9tP582zZMwSvZGy2nmSeAb8DW2me3y+Uv1Wp2Rulr8Mqlyrv3E7MLxmsiaPSMMDmiDVzgE+e8zlMx9g==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/socket.io-v2/node_modules/socket.io-client/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/socket.io-v2/node_modules/socket.io-client/node_modules/socket.io-parser": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-3.3.6.tgz", + "integrity": "sha512-+VwteZF0qtYVkcO1nhKf6ZUVz5wq6Ya+Lx10y3Y81Sp5+iyGGY2GTcd81W36C/r1tjP+GhXjiXaLKlCMyE+yHQ==", + "license": "MIT", + "dependencies": { + "component-emitter": "~1.3.0", + "debug": "~3.1.0", + "isarray": "2.0.1" + } + }, + "node_modules/socket.io-v2/node_modules/socket.io-client/node_modules/socket.io-parser/node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/socket.io-v2/node_modules/socket.io-client/node_modules/socket.io-parser/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/socket.io-v2/node_modules/socket.io-parser": { + "version": "3.4.5", + "resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-3.4.5.tgz", + "integrity": "sha512-nrhzTtwpgt8tN+la+9Tpzj2epV7FMtr+9lqcfpwopjoxBfUXvBR7TWwF+/UT7RiTbFcHiEJdvRZ7yptJgZxo1Q==", + "license": "MIT", + "dependencies": { + "component-emitter": "1.2.1", + "debug": "~4.1.0", + "isarray": "2.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-v2/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/socket.io-v2/node_modules/xmlhttprequest-ssl": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz", + "integrity": "sha512-/bFPLUgJrfGUL10AIv4Y7/CUt6so9CLtB/oFxQSHseSDNNCdC6vwwKEqwLN6wNPBg9YWXAiMu8jkf6RPRS/75Q==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/socket.io/node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", @@ -24238,6 +24652,11 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/to-array": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/to-array/-/to-array-0.1.4.tgz", + "integrity": "sha512-LhVdShQD/4Mk4zXNroIQZJC+Ap3zgLcDuwEdcmLv9CCO73NWockQDwyUnW/m8VX/EElfL6FcYx7EeutN4HJA6A==" + }, "node_modules/to-arraybuffer": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", @@ -26729,6 +27148,12 @@ "decamelize": "^1.2.0" } }, + "node_modules/yeast": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/yeast/-/yeast-0.1.2.tgz", + "integrity": "sha512-8HFIh676uyGYP6wP13R/j6OJ/1HwJ46snpvzE7aHAN3Ryqh2yX6Xox2B4CUmTwwOIzlG3Bs7ocsP5dZH/R1Qbg==", + "license": "MIT" + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 0b30dfc72..5a7a3d243 100644 --- a/package.json +++ b/package.json @@ -179,6 +179,7 @@ "sharp": "0.32.6", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", + "socket.io-v2": "npm:socket.io@2.3.0", "strip-ansi": "^6.0.1", "tga-js": "^1.1.1", "tmp": "^0.0.33", diff --git a/src/api/scene/lightfx-bake-schema.ts b/src/api/scene/lightfx-bake-schema.ts new file mode 100644 index 000000000..47429488e --- /dev/null +++ b/src/api/scene/lightfx-bake-schema.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +const SaveAndTimeout = { + saveScene: z.boolean().optional().describe('Save the current scene after applying the bake result; defaults to true'), + timeoutMs: z.number().int().min(1_000).max(3_600_000).optional().describe('Whole-operation timeout in milliseconds'), +}; + +export const SchemaLightProbeBakeOptions = z.object({ + giScale: z.number().finite().min(0).max(100).optional().describe('GI multiplier for this bake only'), + giSamples: z.number().int().min(64).max(65535).optional().describe('GI probe sample count for this bake only'), + bounces: z.number().int().min(1).max(4).optional().describe('Probe ray bounce count for this bake only'), + ...SaveAndTimeout, +}).describe('Light probe bake options'); + +export const SchemaLightProbeBakeResult = z.object({ + sceneUrl: z.string(), probeCount: z.number().int().nonnegative(), + giScale: z.number(), giSamples: z.number().int(), bounces: z.number().int(), durationMs: z.number().nonnegative(), +}); + +export const SchemaLightmapBakeOptions = z.object({ + msaa: z.union([z.literal(1), z.literal(2), z.literal(4), z.literal(8)]).optional(), + resolution: z.number().int().min(128).max(8192).optional(), + filter: z.boolean().optional(), highp: z.boolean().optional(), + giScale: z.number().finite().min(0).max(100).optional(), + giSamples: z.number().int().min(1).max(65535).optional(), + giPathLength: z.number().int().min(1).max(64).optional(), + aoLevel: z.number().int().min(0).max(2).optional(), + aoStrength: z.number().finite().min(0).optional(), + aoRadius: z.number().finite().min(0).optional(), + aoColor: z.tuple([z.number().min(0).max(255), z.number().min(0).max(255), z.number().min(0).max(255), z.number().min(0).max(255).optional()]).optional(), + threads: z.number().int().min(1).max(256).optional(), + ...SaveAndTimeout, +}).describe('Lightmap bake options'); + +export const SchemaLightmapBakeResult = z.object({ + sceneUrl: z.string(), textureUrls: z.array(z.string()), meshCount: z.number().int().nonnegative(), + terrainCount: z.number().int().nonnegative(), durationMs: z.number().nonnegative(), +}); + +export const SchemaLightFXCancelResult = z.object({ + cancelled: z.boolean(), target: z.enum(['light-probe', 'lightmap']).nullable(), +}); + +export const SchemaLightProbeClearOptions = z.object({ saveScene: z.boolean().optional() }); +export const SchemaLightmapClearOptions = z.object({ saveScene: z.boolean().optional(), deleteAssets: z.boolean().optional() }); +export const SchemaClearCountResult = z.object({ probeCount: z.number().int().nonnegative().optional(), clearedCount: z.number().int().nonnegative().optional() }); + +export type TLightProbeBakeOptions = z.infer; +export type TLightProbeBakeResult = z.infer; +export type TLightmapBakeOptions = z.infer; +export type TLightmapBakeResult = z.infer; diff --git a/src/api/scene/lightfx-bake.ts b/src/api/scene/lightfx-bake.ts new file mode 100644 index 000000000..2d635f0a6 --- /dev/null +++ b/src/api/scene/lightfx-bake.ts @@ -0,0 +1,58 @@ +import { COMMON_STATUS, CommonResultType } from '../base/schema-base'; +import { description, param, result, title, tool } from '../decorator/decorator'; +import { Scene } from '../../core/scene'; +import { + SchemaClearCountResult, SchemaLightFXCancelResult, SchemaLightmapBakeOptions, SchemaLightmapBakeResult, + SchemaLightmapClearOptions, SchemaLightProbeBakeOptions, SchemaLightProbeBakeResult, SchemaLightProbeClearOptions, + TLightmapBakeOptions, TLightmapBakeResult, TLightProbeBakeOptions, TLightProbeBakeResult, +} from './lightfx-bake-schema'; + +async function execute(operation: () => Promise): Promise> { + try { return { code: COMMON_STATUS.SUCCESS, data: await operation() }; } + catch (error) { return { code: COMMON_STATUS.FAIL, reason: error instanceof Error ? error.message : String(error) }; } +} + +export class LightFXBakeApi { + @tool('scene-bake-light-probes') + @title('Bake light probes') + @description('Bake all light probes in the current scene with LightFX and write spherical-harmonic coefficients back to the scene.') + @result(SchemaLightProbeBakeResult) + bakeLightProbes(@param(SchemaLightProbeBakeOptions) options: TLightProbeBakeOptions): Promise> { + return execute(() => Scene.LightProbeBake.bake(options)); + } + + @tool('scene-clear-light-probes') + @title('Clear baked light probes') + @description('Clear spherical-harmonic bake results from all light probes in the current scene.') + @result(SchemaClearCountResult) + clearLightProbes(@param(SchemaLightProbeClearOptions) options: { saveScene?: boolean }): Promise> { + return execute(() => Scene.LightProbeBake.clearBake(options)); + } + + @tool('scene-bake-lightmap') + @title('Bake lightmap') + @description('Bake the current scene lightmap with LightFX, import generated textures, bind them to renderers and save the scene.') + @result(SchemaLightmapBakeResult) + bakeLightmap(@param(SchemaLightmapBakeOptions) options: TLightmapBakeOptions): Promise> { + return execute(() => Scene.LightmapBake.bake(options)); + } + + @tool('scene-clear-lightmap') + @title('Clear baked lightmap') + @description('Unbind baked lightmaps from the current scene and optionally delete generated assets.') + @result(SchemaClearCountResult) + clearLightmap(@param(SchemaLightmapClearOptions) options: { saveScene?: boolean; deleteAssets?: boolean }): Promise> { + return execute(() => Scene.LightmapBake.clearBake(options)); + } + + @tool('scene-cancel-lightfx-bake') + @title('Cancel LightFX bake') + @description('Cancel the currently running light-probe or lightmap bake.') + @result(SchemaLightFXCancelResult) + cancel(): Promise> { + return execute(async () => { + const probe = await Scene.LightProbeBake.cancel(); + return probe.cancelled ? probe : Scene.LightmapBake.cancel(); + }); + } +} diff --git a/src/api/scene/scene.ts b/src/api/scene/scene.ts index 94538dabb..0a1a6c0d1 100644 --- a/src/api/scene/scene.ts +++ b/src/api/scene/scene.ts @@ -23,6 +23,7 @@ import { ComponentApi } from './component'; import { NodeApi } from './node'; import { PrefabApi } from './prefab'; import { ReferenceImageApi } from './reference-image'; +import { LightFXBakeApi } from './lightfx-bake'; import { options } from '../../core/builder/platforms/android/i18n/en'; export class SceneApi { @@ -30,12 +31,14 @@ export class SceneApi { public node: NodeApi; public prefab: PrefabApi; public referenceImage: ReferenceImageApi; + public lightFXBake: LightFXBakeApi; constructor() { this.component = new ComponentApi(); this.node = new NodeApi(); this.prefab = new PrefabApi(); this.referenceImage = new ReferenceImageApi(); + this.lightFXBake = new LightFXBakeApi(); } @tool('scene-query-current') diff --git a/src/core/scene/common/index.ts b/src/core/scene/common/index.ts index 04185174f..d628eeb97 100644 --- a/src/core/scene/common/index.ts +++ b/src/core/scene/common/index.ts @@ -18,3 +18,4 @@ export * from './preview'; export * from './ui'; export * from './message'; export * from './reference-image'; +export * from './lightfx-bake'; diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts new file mode 100644 index 000000000..522b57929 --- /dev/null +++ b/src/core/scene/common/lightfx-bake.ts @@ -0,0 +1,68 @@ +import type { IServiceEvents } from '../scene-process/service/core'; + +export interface ILightProbeBakeOptions { + giScale?: number; + giSamples?: number; + bounces?: number; + saveScene?: boolean; + timeoutMs?: number; +} + +export interface ILightProbeBakeResult { + sceneUrl: string; + probeCount: number; + giScale: number; + giSamples: number; + bounces: number; + durationMs: number; +} + +export interface ILightmapBakeOptions { + msaa?: 1 | 2 | 4 | 8; + resolution?: number; + filter?: boolean; + highp?: boolean; + giScale?: number; + giSamples?: number; + giPathLength?: number; + aoLevel?: number; + aoStrength?: number; + aoRadius?: number; + aoColor?: [number, number, number, number?]; + threads?: number; + saveScene?: boolean; + timeoutMs?: number; +} + +export interface ILightmapBakeResult { + sceneUrl: string; + textureUrls: string[]; + meshCount: number; + terrainCount: number; + durationMs: number; +} + +export interface ILightFXCancelResult { + cancelled: boolean; + target: 'light-probe' | 'lightmap' | null; +} + +export interface ILightFXBakeEvents { + 'lightfx:bake-start': [target: 'light-probe' | 'lightmap']; + 'lightfx:bake-end': [target: 'light-probe' | 'lightmap', error?: string]; +} + +export interface ILightProbeBakeService extends IServiceEvents { + bake(options: ILightProbeBakeOptions): Promise; + clearBake(options?: { saveScene?: boolean }): Promise<{ probeCount: number }>; + cancel(): Promise; +} + +export interface ILightmapBakeService extends IServiceEvents { + bake(options: ILightmapBakeOptions): Promise; + clearBake(options?: { saveScene?: boolean; deleteAssets?: boolean }): Promise<{ clearedCount: number }>; + cancel(): Promise; +} + +export type IPublicLightProbeBakeService = Pick; +export type IPublicLightmapBakeService = Pick; diff --git a/src/core/scene/main-process/index.ts b/src/core/scene/main-process/index.ts index a69ebfc95..65f5d4512 100644 --- a/src/core/scene/main-process/index.ts +++ b/src/core/scene/main-process/index.ts @@ -7,6 +7,7 @@ import { AssetProxy } from './proxy/asset-proxy'; import { EngineProxy } from './proxy/engine-proxy'; import { PrefabProxy } from './proxy/prefab-proxy'; import { ReferenceImageProxy } from './proxy/reference-image-proxy'; +import { LightmapBakeProxy, LightProbeBakeProxy } from './proxy/lightfx-bake-proxy'; import { assetManager } from '../../assets'; import scriptManager from '../../scripting'; @@ -31,6 +32,8 @@ export const Scene = { ...EngineProxy, ...PrefabProxy, ReferenceImage: ReferenceImageProxy, + LightProbeBake: LightProbeBakeProxy, + LightmapBake: LightmapBakeProxy, // 节点相关的接口 Node: NodeProxy, // 组件相关的接口 diff --git a/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts b/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts new file mode 100644 index 000000000..e4e33130b --- /dev/null +++ b/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts @@ -0,0 +1,14 @@ +import type { IPublicLightProbeBakeService, IPublicLightmapBakeService } from '../../common'; +import { Rpc } from '../rpc'; + +export const LightProbeBakeProxy: IPublicLightProbeBakeService = { + bake: (options) => Rpc.getInstance().request('LightProbeBake', 'bake', [options]), + clearBake: (options) => Rpc.getInstance().request('LightProbeBake', 'clearBake', [options]), + cancel: () => Rpc.getInstance().request('LightProbeBake', 'cancel'), +}; + +export const LightmapBakeProxy: IPublicLightmapBakeService = { + bake: (options) => Rpc.getInstance().request('LightmapBake', 'bake', [options]), + clearBake: (options) => Rpc.getInstance().request('LightmapBake', 'clearBake', [options]), + cancel: () => Rpc.getInstance().request('LightmapBake', 'cancel'), +}; diff --git a/src/core/scene/scene-process/service/baking/lightfx/asset-transaction.ts b/src/core/scene/scene-process/service/baking/lightfx/asset-transaction.ts new file mode 100644 index 000000000..39f4518f0 --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/asset-transaction.ts @@ -0,0 +1,29 @@ +import { copy, ensureDir, pathExists, remove } from 'fs-extra'; +import { dirname, join } from 'path'; + +export class LightmapAssetTransaction { + private readonly backupDir: string; + private hadTarget = false; + private prepared = false; + + constructor(private readonly targetDir: string, workspace: string) { + this.backupDir = join(workspace, 'lightmap-asset-backup'); + } + + async prepare(): Promise { + this.hadTarget = await pathExists(this.targetDir); + if (this.hadTarget) await copy(this.targetDir, this.backupDir); + await remove(this.targetDir); + await ensureDir(this.targetDir); + this.prepared = true; + } + + async rollback(): Promise { + if (!this.prepared) return; + await remove(this.targetDir); + if (this.hadTarget) { + await ensureDir(dirname(this.targetDir)); + await copy(this.backupDir, this.targetDir); + } + } +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/baker.ts b/src/core/scene/scene-process/service/baking/lightfx/baker.ts new file mode 100644 index 000000000..eb9404882 --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/baker.ts @@ -0,0 +1,29 @@ +import { Scene } from 'cc'; +import { ensureDir, outputFile, readFile, remove } from 'fs-extra'; +import { dirname, join } from 'path'; +import { Rpc } from '../../../rpc'; +import { decodeLightFXOutput, encodeLightFXInput } from './format'; +import { LightFXExporter, LightFXExport } from './exporter'; +import { LightFXProcess } from './process'; +import { LightFXBakeTarget, LightFXResult, LightFXSettings } from './types'; + +export interface LightFXBakeOutput extends LightFXExport { result: LightFXResult; workspace: string; outputDir: string } + +class LightFXCoordinator { + private target: LightFXBakeTarget | null = null; private controller: AbortController | null = null; private runner: LightFXProcess | null = null; + get activeTarget(): LightFXBakeTarget | null { return this.target; } + async bake(scene: Scene, target: LightFXBakeTarget, settings: LightFXSettings, timeoutMs: number): Promise { + if (this.target) throw new Error(`A ${this.target} LightFX bake is already in progress.`); this.target = target; this.controller = new AbortController(); this.runner = new LightFXProcess(); + 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 projectRoot = dirname(assetRoot); const workspace = join(projectRoot, 'temp', 'lightfx-bake', `${target}-${Date.now()}-${process.pid}`); const tmpDir = join(workspace, 'tmp'); const outputDir = join(workspace, 'output'); + try { + await ensureDir(tmpDir); await ensureDir(outputDir); const exported = await new LightFXExporter(tmpDir, projectRoot).export(scene, target, settings); + await outputFile(join(tmpDir, 'lfx.in'), encodeLightFXInput(exported.world)); + await this.runner.run({ cwd: workspace, timeoutMs, signal: this.controller.signal, onLog: (line) => console.log(`[LightFX] ${line}`) }); + const result = decodeLightFXOutput(await readFile(join(outputDir, 'lfx.out'))); return { ...exported, result, workspace, outputDir }; + } catch (error) { await remove(workspace).catch(() => undefined); throw error; } + finally { this.target = null; this.controller = null; this.runner = null; } + } + async cancel(): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }> { const target = this.target; if (!target) return { cancelled: false, target: null }; this.controller?.abort(); await this.runner?.cancel(); return { cancelled: true, target }; } +} +export const lightFXCoordinator = new LightFXCoordinator(); diff --git a/src/core/scene/scene-process/service/baking/lightfx/buffer.ts b/src/core/scene/scene-process/service/baking/lightfx/buffer.ts new file mode 100644 index 000000000..4f934f40e --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/buffer.ts @@ -0,0 +1,31 @@ +const MAX_COLLECTION_LENGTH = 10_000_000; + +export class LightFXBuffer { + private data: Uint8Array; + private view: DataView; + private cursor = 0; + private length = 0; + + constructor(input?: Uint8Array) { + this.data = input ?? new Uint8Array(2048); + this.view = new DataView(this.data.buffer, this.data.byteOffset, this.data.byteLength); + this.length = input?.byteLength ?? 0; + } + toUint8Array(): Uint8Array { return this.data.slice(0, this.length); } + get remaining(): number { return this.length - this.cursor; } + writeInt8(value: number): void { this.reserve(1); this.view.setInt8(this.length, value); this.length++; } + writeInt32(value: number): void { this.reserve(4); this.view.setInt32(this.length, value, true); this.length += 4; } + writeFloat(value: number): void { this.reserve(4); this.view.setFloat32(this.length, value, true); this.length += 4; } + writeInts(values: number[]): void { values.forEach((value) => this.writeInt32(value)); } + writeFloats(values: number[]): void { values.forEach((value) => this.writeFloat(value)); } + writeHeightField(values: Uint16Array): void { this.reserve(values.length * 2); for (const value of values) { this.view.setUint16(this.length, value, true); this.length += 2; } } + writeString(value: string): void { const encoded = Buffer.from(value, 'utf8'); this.writeInt32(encoded.length); this.reserve(encoded.length); this.data.set(encoded, this.length); this.length += encoded.length; } + readInt8(): number { this.ensure(1); const value = this.view.getInt8(this.cursor); this.cursor++; return value; } + readInt32(): number { this.ensure(4); const value = this.view.getInt32(this.cursor, true); this.cursor += 4; return value; } + readFloat(): number { this.ensure(4); const value = this.view.getFloat32(this.cursor, true); this.cursor += 4; if (!Number.isFinite(value)) throw new Error('LightFX output contains a non-finite float.'); return value; } + readFloats(count: number): number[] { this.validateCount(count); return Array.from({ length: count }, () => this.readFloat()); } + readCount(label: string): number { const count = this.readInt32(); if (count < 0 || count > MAX_COLLECTION_LENGTH) throw new Error(`Invalid LightFX ${label} count: ${count}.`); return count; } + private validateCount(count: number): void { if (!Number.isInteger(count) || count < 0 || count > MAX_COLLECTION_LENGTH) throw new Error(`Invalid LightFX array length: ${count}.`); } + private ensure(size: number): void { if (this.cursor + size > this.length) throw new Error('LightFX output is truncated.'); } + private reserve(size: number): void { const required = this.length + size; if (required <= this.data.byteLength) return; let capacity = this.data.byteLength || 1; while (capacity < required) capacity *= 2; const next = new Uint8Array(capacity); next.set(this.data); this.data = next; this.view = new DataView(next.buffer); } +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/exporter.ts b/src/core/scene/scene-process/service/baking/lightfx/exporter.ts new file mode 100644 index 000000000..04495292d --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/exporter.ts @@ -0,0 +1,101 @@ +import { DirectionalLight, director, gfx, Light, MeshRenderer, MobilityMode, renderer, Scene, SphereLight, SpotLight, Terrain, Texture2D, Vec3 } from 'cc'; +import { basename, join } from 'path'; +import { copy, pathExists } from 'fs-extra'; +import { Rpc } from '../../../rpc'; +import { LightFXBakeTarget, LightFXLight, LightFXMaterial, LightFXMesh, LightFXSettings, LightFXTerrain, LightFXWorld } from './types'; + +export interface LightFXExport { world: LightFXWorld; models: MeshRenderer[]; terrains: Terrain[]; stationaryMainLight: boolean } + +export class LightFXExporter { + constructor(private readonly textureDir: string, private readonly projectRoot: string) {} + + async export(scene: Scene, target: LightFXBakeTarget, settings: LightFXSettings): Promise { + const world: LightFXWorld = { name: scene.name, settings, meshes: [], terrains: [], lights: [], probes: [], textures: [] }; + const models: MeshRenderer[] = []; const terrains: Terrain[] = []; + let hasMainLight = false; let stationaryMainLight = false; + const hdr = director.root?.pipeline.pipelineSceneData.isHDR ?? false; + const visit = async (node: any): Promise => { + if (node !== scene && (node.mobility === MobilityMode.Movable || !node.activeInHierarchy || (node._objFlags & (1 << 10)))) return; + if (node !== scene) { + const terrain = node.getComponent(Terrain) as Terrain | null; + if (terrain?.enabled) { world.terrains.push(this.exportTerrain(terrain)); terrains.push(terrain); } + for (const model of node.getComponents(MeshRenderer) as MeshRenderer[]) { + if (!model.enabled) continue; const exported = await this.exportMesh(model, target); + if (exported) { world.meshes.push(exported); models.push(model); } + } + for (const light of node.getComponents(Light) as Light[]) if (light.enabled) { const exported = this.exportLight(light, hdr); if (exported) { world.lights.push(exported); if (!hasMainLight && light instanceof DirectionalLight) { hasMainLight = true; stationaryMainLight = light.node.mobility === MobilityMode.Stationary; } } } + } + for (const child of node.children) await visit(child); + }; + await visit(scene); + const exposure = hdr ? renderer.scene.Camera.standardExposureValue : 1; + for (const light of world.lights) light.color = light.color.map((value) => value * exposure); + if (scene.globals.lightProbeInfo.data) for (const probe of scene.globals.lightProbeInfo.data.probes) world.probes.push({ position: [probe.position.x, probe.position.y, probe.position.z], normal: [probe.normal.x, probe.normal.y, probe.normal.z] }); + return { world, models, terrains, stationaryMainLight }; + } + + private exportTerrain(terrain: Terrain): LightFXTerrain { + const p = terrain.node.worldPosition; const info: any = terrain.info; + return { position: [p.x, p.y, p.z], tileSize: info.tileSize, blockCount: [...info.blockCount], lightmapSize: info.lightMapSize, heightField: (terrain as any).getHeightField() }; + } + + private async exportMesh(model: MeshRenderer, target: LightFXBakeTarget): Promise { + const mesh: any = model.mesh; if (!mesh) return null; const bake: any = model.bakeSettings; + if (target === 'light-probe' && !bake.bakeToLightProbe) return null; + if (target === 'lightmap' && !bake.bakeable && !bake.castShadow) return null; + const out: LightFXMesh = { castShadow: target === 'light-probe' ? true : bake.castShadow, receiveShadow: target === 'lightmap' && bake.receiveShadow, lightmapSize: target === 'lightmap' && bake.bakeable ? bake.lightmapSize : 0, vertices: [], triangles: [], materials: [] }; + const matrix = model.node.worldMatrix; let start = 0; + for (let primitive = 0; primitive < mesh.struct.primitives.length; primitive++) { + const positions: any = mesh.readAttribute(primitive, gfx.AttributeName.ATTR_POSITION); const normals: any = mesh.readAttribute(primitive, gfx.AttributeName.ATTR_NORMAL); const indices: any = mesh.readIndices(primitive); + const uvs: any = mesh.readAttribute(primitive, gfx.AttributeName.ATTR_TEX_COORD); const luvs: any = mesh.readAttribute(primitive, gfx.AttributeName.ATTR_TEX_COORD1); + if (!positions || !normals || !indices || positions.length !== normals.length) throw new Error(`Mesh has invalid position, normal or index data: ${model.node.name}`); + if (target === 'lightmap' && out.lightmapSize > 0 && !luvs) throw new Error(`Mesh is missing lightmap UV: ${model.node.name}`); + for (let i = 0; i < positions.length / 3; i++) { + const p = new Vec3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]); const n = new Vec3(normals[i * 3], normals[i * 3 + 1], normals[i * 3 + 2]); + Vec3.transformMat4(p, p, matrix); Vec3.transformMat4Normal(n, n, matrix).normalize(); + out.vertices.push({ position: [p.x, p.y, p.z], normal: [n.x, n.y, n.z], uv: uvs ? [uvs[i * 2], uvs[i * 2 + 1]] : [0, 0], lightmapUV: luvs ? [luvs[i * 2], luvs[i * 2 + 1]] : [0, 0] }); + } + for (let i = 0; i < indices.length; i += 3) out.triangles.push({ indices: [indices[i] + start, indices[i + 1] + start, indices[i + 2] + start], materialId: Math.min(primitive, Math.max(0, model.materials.length - 1)) }); + start = out.vertices.length; + } + if (model.materials.length) for (const material of model.materials) out.materials.push(await this.exportMaterial(material)); + else out.materials.push(this.defaultMaterial()); + return out; + } + + private async exportMaterial(material: any): Promise { + const out = this.defaultMaterial(); if (!material) return out; + const color = material.getProperty('mainColor', 0); if (color) out.diffuse = [color.x, color.y, color.z]; + const emissive = material.getProperty('emissive', 0); if (emissive) out.emissive = [emissive.x, emissive.y, emissive.z]; + this.applyColorScale(out.diffuse, material.getProperty('albedoScale', 0)); + this.applyColorScale(out.emissive, material.getProperty('emissiveScale', 0)); + out.metallic = Number(material.getProperty('metallic', 0) ?? 0.6); out.roughness = Number(material.getProperty('roughness', 0) ?? 0.8); out.alphaCutoff = Number(material.getProperty('alphaThreshold', 0) ?? 0.5); + out.texture = await this.resolveTexture(material.getProperty('mainTexture', 0) ?? material.getProperty('albedoMap', 0)); + out.pbrMap = await this.resolveTexture(material.getProperty('pbrMap', 0)); out.emissiveMap = await this.resolveTexture(material.getProperty('emissiveMap', 0)); + return out; + } + private defaultMaterial(): LightFXMaterial { return { alphaCutoff: 0.5, metallic: 0.6, roughness: 0.8, diffuse: [1, 1, 1], emissive: [0, 0, 0], texture: '', pbrMap: '', emissiveMap: '' }; } + private applyColorScale(color: number[], scale: Vec3 | number | null): void { + if (typeof scale === 'number') { color[0] *= scale; color[1] *= scale; color[2] *= scale; } + else if (scale) { color[0] *= scale.x; color[1] *= scale.y; color[2] *= scale.z; } + } + private async resolveTexture(texture: Texture2D | null): Promise { + const pixelFormat = Texture2D.PixelFormat; + if (texture && texture.getPixelFormat() !== pixelFormat.RGBA8888 && texture.getPixelFormat() !== pixelFormat.RGB888) return ''; + const image: any = texture?.mipmaps?.[0]; if (!image?._uuid) return ''; + const uuid = String(image._uuid); let source: string | null; + if (uuid.includes('@')) source = join(this.projectRoot, 'library', uuid.slice(0, 2), `${uuid}${image._native ?? ''}`); + else source = await Rpc.getInstance().request('assetManager', 'queryPath', [uuid]) as string | null; + if (!source || !(await pathExists(source))) return ''; + const name = `${uuid.replace(/[^a-zA-Z0-9_.-]/g, '_')}-${basename(source)}`; await copy(source, join(this.textureDir, name)); return name; + } + + private exportLight(light: Light, hdr: boolean): LightFXLight | null { + const p = light.node.worldPosition; const d = new Vec3(0, 0, -1); Vec3.transformQuat(d, d, light.node.worldRotation); const c: any = light.color; + const out: LightFXLight = { type: 2, position: [p.x, p.y, p.z], direction: [d.x, d.y, d.z], color: [c.x, c.y, c.z], size: 0, range: 0, attenuationFalloff: 1, spotInner: 1, spotOuter: .7071, spotFalloff: 1, directScale: light.node.mobility === MobilityMode.Static ? 1 : 0, indirectScale: 1, giEnabled: true, castShadow: (light as any).staticSettings.castShadow, shadowMask: 0 }; + if (light instanceof DirectionalLight) { out.type = 2; out.shadowMask = 1 - light.shadowSaturation; out.color = out.color.map((v) => v * light.illuminance); } + else if (light instanceof SphereLight) { out.type = 0; out.size = light.size; out.range = light.range; out.color = out.color.map((v) => v * light.luminance * (hdr ? 10_000 : 1)); } + else if (light instanceof SpotLight) { out.type = 1; out.size = light.size; out.range = light.range; out.spotInner = Math.cos(light.spotAngle / 4 * Math.PI / 180); out.spotOuter = Math.cos(light.spotAngle / 2 * Math.PI / 180); out.color = out.color.map((v) => v * light.luminance * (hdr ? 10_000 : 1)); } + else return null; return out; + } +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/format.ts b/src/core/scene/scene-process/service/baking/lightfx/format.ts new file mode 100644 index 000000000..6c3536472 --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/format.ts @@ -0,0 +1,36 @@ +import { LightFXBuffer } from './buffer'; +import { LIGHTFX_FILE_VERSION, LIGHTFX_OUTPUT_VERSIONS, LightFXChunk, LightFXResult, LightFXWorld } from './types'; + +export function encodeLightFXInput(world: LightFXWorld): Uint8Array { + const b = new LightFXBuffer(); const s = world.settings; + b.writeInt32(LIGHTFX_FILE_VERSION); b.writeString(world.name); b.writeFloats([0, 0, 0]); b.writeFloats(s.skyRadiance); + b.writeInt32(s.msaa); b.writeInt32(s.size); b.writeFloat(s.gamma); b.writeInt8(s.highp ? 1 : 0); + b.writeFloat(s.giScale); b.writeInt32(s.giSamples); b.writeInt32(s.giPathLength); + b.writeFloat(s.giProbeScale); b.writeInt32(s.giProbeSamples); b.writeInt32(s.giProbePathLength); + b.writeInt32(s.aoLevel); b.writeFloat(s.aoStrength); b.writeFloat(s.aoRadius); b.writeFloats(s.aoColor.slice(0, 3).map((v) => v / 255)); + b.writeInt32(s.threads); b.writeInt8(s.filter ? 1 : 0); b.writeInt8(s.bakeLightmap ? 1 : 0); b.writeInt8(s.bakeLightProbe ? 1 : 0); + for (const t of world.terrains) { b.writeInt32(LightFXChunk.TERRAIN); b.writeFloats(t.position); b.writeFloat(t.tileSize); b.writeInts(t.blockCount); b.writeInt32(t.lightmapSize); b.writeHeightField(t.heightField); } + for (const m of world.meshes) { + b.writeInt32(LightFXChunk.MESH); b.writeInt8(m.castShadow ? 1 : 0); b.writeInt8(m.receiveShadow ? 1 : 0); b.writeInt32(m.lightmapSize); + b.writeInt32(m.vertices.length); b.writeInt32(m.triangles.length); b.writeInt32(m.materials.length); + m.vertices.forEach((v) => { b.writeFloats(v.position); b.writeFloats(v.normal); b.writeFloats(v.uv); b.writeFloats(v.lightmapUV); }); + m.triangles.forEach((t) => { b.writeInts(t.indices); b.writeInt32(t.materialId); }); + m.materials.forEach((m) => { b.writeFloat(m.alphaCutoff); b.writeFloat(m.metallic); b.writeFloat(m.roughness); b.writeFloats(m.diffuse); b.writeFloats(m.emissive); b.writeString(m.texture); b.writeString(m.pbrMap); b.writeString(m.emissiveMap); }); + } + for (const l of world.lights) { b.writeInt32(LightFXChunk.LIGHT); b.writeInt32(l.type); b.writeFloats(l.position); b.writeFloats(l.direction); b.writeFloats(l.color); b.writeFloat(l.size); b.writeFloat(l.range); b.writeFloat(l.attenuationFalloff); b.writeFloat(l.spotInner); b.writeFloat(l.spotOuter); b.writeFloat(l.spotFalloff); b.writeFloat(l.directScale); b.writeFloat(l.indirectScale); b.writeInt8(l.giEnabled ? 1 : 0); b.writeInt8(l.castShadow ? 1 : 0); b.writeFloat(l.shadowMask); } + for (const p of world.probes) { b.writeInt32(LightFXChunk.LIGHT_PROBE); b.writeFloats(p.position); b.writeFloats(p.normal); } + b.writeInt32(LightFXChunk.EOF); return b.toUint8Array(); +} + +export function decodeLightFXOutput(input: Uint8Array): LightFXResult { + const b = new LightFXBuffer(input); const result: LightFXResult = { version: b.readInt32(), meshes: [], terrains: [], probes: [] }; + if (!LIGHTFX_OUTPUT_VERSIONS.has(result.version)) throw new Error(`Unsupported LightFX output version: 0x${result.version.toString(16)}.`); + while (b.remaining > 0) { + const chunk = b.readInt32(); if (chunk === LightFXChunk.EOF) return result; + if (chunk === LightFXChunk.TERRAIN) { const id = b.readInt32(); const count = b.readCount('terrain'); for (let i = 0; i < count; i++) result.terrains.push({ id, blockId: b.readInt32(), index: b.readInt32(), offset: b.readFloats(2), scale: b.readFloats(2) }); } + else if (chunk === LightFXChunk.MESH) { const count = b.readCount('mesh'); for (let i = 0; i < count; i++) result.meshes.push({ id: b.readInt32(), index: b.readInt32(), offset: b.readFloats(2), scale: b.readFloats(2) }); } + else if (chunk === LightFXChunk.LIGHT_PROBE) { const count = b.readCount('light probe'); for (let i = 0; i < count; i++) result.probes.push({ position: b.readFloats(3), normal: b.readFloats(3), coefficients: b.readFloats(b.readCount('coefficient')) }); } + else throw new Error(`Unknown LightFX output chunk: ${chunk}.`); + } + throw new Error('LightFX output has no EOF chunk.'); +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/process.ts b/src/core/scene/scene-process/service/baking/lightfx/process.ts new file mode 100644 index 000000000..a9d7c52eb --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/process.ts @@ -0,0 +1,73 @@ +import { ChildProcess, spawn } from 'child_process'; +import { createServer, Server as HttpServer } from 'http'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { GlobalPaths } from '../../../../../../global'; + +// LightFX embeds a Socket.IO 2.x client which cannot connect to the project's +// Socket.IO 4.x server even with Engine.IO 3 compatibility enabled. +const createLegacySocketServer = require('socket.io-v2') as (server: HttpServer, options: object) => any; + +export interface LightFXProcessOptions { + cwd: string; + timeoutMs: number; + signal?: AbortSignal; + onLog?: (message: string) => void; + onProgress?: (progress: unknown) => void; +} + +export class LightFXProcess { + private child: ChildProcess | null = null; + private http: HttpServer | null = null; + private io: any = null; + private settled = false; + + async run(options: LightFXProcessOptions): Promise { + if (this.child || this.io) throw new Error('LightFX process is already running.'); + const executable = join(GlobalPaths.staticDir, 'tools', 'lightmap-tools', process.platform === 'win32' ? 'LightFX.exe' : 'LightFX'); + if (!existsSync(executable)) throw new Error(`LightFX executable was not found: ${executable}`); + this.settled = false; + await new Promise((resolve, reject) => { + let timer: NodeJS.Timeout; + const finish = async (error?: unknown): Promise => { + if (this.settled) return; + this.settled = true; + clearTimeout(timer); + options.signal?.removeEventListener('abort', abort); + await this.close(); + if (error) reject(error instanceof Error ? error : new Error(String(error))); else resolve(); + }; + const fail = (error: unknown): void => { void finish(error); }; + const succeed = (): void => { void finish(); }; + timer = setTimeout(() => fail(new Error('LightFX bake timed out.')), options.timeoutMs); + const abort = (): void => fail(new Error('LightFX bake was cancelled.')); + options.signal?.addEventListener('abort', abort, { once: true }); + void (async () => { try { + this.http = createServer(); + this.io = createLegacySocketServer(this.http, { serveClient: false, transports: ['websocket', 'polling'] }); + this.io.on('connection', (socket: any) => { + socket.once('Login', () => socket.emit('Start')); + socket.on('Log', (data: unknown) => options.onLog?.(String(data))); + socket.on('Progress', (data: unknown) => options.onProgress?.(data)); + socket.once('Finished', () => { socket.emit('Stop'); succeed(); }); + }); + await new Promise((ready, listenReject) => { + this.http!.once('error', listenReject); + this.http!.listen(0, '127.0.0.1', () => ready()); + }); + const address = this.http.address(); + if (!address || typeof address === 'string') throw new Error('LightFX server did not allocate a TCP port.'); + this.child = spawn(executable, [`http://127.0.0.1:${address.port}`], { cwd: options.cwd, windowsHide: true }); + this.child.once('error', fail); + this.child.once('exit', (code, signal) => { if (!this.settled) fail(new Error(`LightFX exited before completion (code=${code}, signal=${signal}).`)); }); + } catch (error) { fail(error); } })(); + }); + } + + async cancel(): Promise { const running = Boolean(this.child || this.io); this.settled = true; await this.close(); return running; } + private async close(): Promise { + if (this.child) { this.child.kill(); this.child = null; } + if (this.io) { await new Promise((resolve) => this.io!.close(() => resolve())); this.io = null; } + if (this.http) { if (this.http.listening) await new Promise((resolve) => this.http!.close(() => resolve())); this.http = null; } + } +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/settings.ts b/src/core/scene/scene-process/service/baking/lightfx/settings.ts new file mode 100644 index 000000000..7a031da96 --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/settings.ts @@ -0,0 +1,25 @@ +import { LightFXBakeTarget, LightFXSettings } from './types'; + +export function createDefaultLightFXSettings(target: LightFXBakeTarget): LightFXSettings { + return { + msaa: 4, + size: 1024, + gamma: 2.2, + highp: false, + skyRadiance: [0, 0, 0], + giScale: 1, + giSamples: 25, + giPathLength: 4, + giProbeScale: 1, + giProbeSamples: 1024, + giProbePathLength: 2, + aoLevel: 0, + aoStrength: 0.5, + aoRadius: 1, + aoColor: [136, 136, 136], + threads: 1, + filter: true, + bakeLightmap: target === 'lightmap', + bakeLightProbe: target === 'light-probe', + }; +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/types.ts b/src/core/scene/scene-process/service/baking/lightfx/types.ts new file mode 100644 index 000000000..9f60e0178 --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/types.ts @@ -0,0 +1,25 @@ +export type LightFXBakeTarget = 'light-probe' | 'lightmap'; + +export interface LightFXSettings { + msaa: number; size: number; gamma: number; highp: boolean; skyRadiance: number[]; + giScale: number; giSamples: number; giPathLength: number; + giProbeScale: number; giProbeSamples: number; giProbePathLength: number; + aoLevel: number; aoStrength: number; aoRadius: number; aoColor: number[]; + threads: number; filter: boolean; bakeLightmap: boolean; bakeLightProbe: boolean; +} +export interface LightFXVertex { position: number[]; normal: number[]; uv: number[]; lightmapUV: number[] } +export interface LightFXTriangle { indices: number[]; materialId: number } +export interface LightFXMaterial { alphaCutoff: number; metallic: number; roughness: number; diffuse: number[]; emissive: number[]; texture: string; pbrMap: string; emissiveMap: string } +export interface LightFXMesh { castShadow: boolean; receiveShadow: boolean; lightmapSize: number; vertices: LightFXVertex[]; triangles: LightFXTriangle[]; materials: LightFXMaterial[] } +export interface LightFXTerrain { position: number[]; tileSize: number; blockCount: number[]; lightmapSize: number; heightField: Uint16Array } +export interface LightFXLight { type: number; position: number[]; direction: number[]; color: number[]; size: number; range: number; attenuationFalloff: number; spotInner: number; spotOuter: number; spotFalloff: number; directScale: number; indirectScale: number; giEnabled: boolean; castShadow: boolean; shadowMask: number } +export interface LightFXProbe { position: number[]; normal: number[] } +export interface LightFXWorld { name: string; settings: LightFXSettings; meshes: LightFXMesh[]; terrains: LightFXTerrain[]; lights: LightFXLight[]; probes: LightFXProbe[]; textures: string[] } +export interface LightFXMeshResult { id: number; index: number; offset: number[]; scale: number[] } +export interface LightFXTerrainResult extends LightFXMeshResult { blockId: number } +export interface LightFXProbeResult { position: number[]; normal: number[]; coefficients: number[] } +export interface LightFXResult { version: number; meshes: LightFXMeshResult[]; terrains: LightFXTerrainResult[]; probes: LightFXProbeResult[] } + +export const LIGHTFX_FILE_VERSION = 0x3730; +export const LIGHTFX_OUTPUT_VERSIONS = new Set([0x2000, 0x2002, 0x2003, LIGHTFX_FILE_VERSION]); +export const enum LightFXChunk { EOF = 0, TERRAIN = 1, MESH = 2, LIGHT = 3, LIGHT_PROBE = 4 } diff --git a/src/core/scene/scene-process/service/index.ts b/src/core/scene/scene-process/service/index.ts index 90fb5273a..d170de166 100644 --- a/src/core/scene/scene-process/service/index.ts +++ b/src/core/scene/scene-process/service/index.ts @@ -22,4 +22,6 @@ export * from './ui'; // registration module instead of replacing its CommonJS side-effect import // with an empty tree-shaken namespace. export { ReferenceImageService } from './reference-image'; +export { LightProbeBakeService } from './light-probe-bake'; +export { LightmapBakeService } from './lightmap-bake'; 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 6dd7cc40f..401686234 100644 --- a/src/core/scene/scene-process/service/interfaces.ts +++ b/src/core/scene/scene-process/service/interfaces.ts @@ -34,6 +34,10 @@ import { IAnimationService, IPublicReferenceImageService, IReferenceImageService, + IPublicLightProbeBakeService, + ILightProbeBakeService, + IPublicLightmapBakeService, + ILightmapBakeService, } from '../../common'; /** @@ -57,6 +61,8 @@ export interface IPublicServiceManager { Preview: IPublicPreviewService, UI: IPublicUIService, ReferenceImage: IPublicReferenceImageService, + LightProbeBake: IPublicLightProbeBakeService, + LightmapBake: IPublicLightmapBakeService, } export interface IServiceManager { @@ -78,4 +84,6 @@ export interface IServiceManager { Preview: IPreviewService, UI: IUIService, ReferenceImage: IReferenceImageService, + LightProbeBake: ILightProbeBakeService, + LightmapBake: ILightmapBakeService, } diff --git a/src/core/scene/scene-process/service/light-probe-bake.ts b/src/core/scene/scene-process/service/light-probe-bake.ts new file mode 100644 index 000000000..ea2bf9960 --- /dev/null +++ b/src/core/scene/scene-process/service/light-probe-bake.ts @@ -0,0 +1,147 @@ +import { director, Scene, SH, Vec3 } from 'cc'; +import { remove } from 'fs-extra'; +import type { + ILightFXBakeEvents, + ILightFXCancelResult, + ILightProbeBakeOptions, + ILightProbeBakeResult, + ILightProbeBakeService, +} from '../../common'; +import { lightFXCoordinator, LightFXBakeOutput } from './baking/lightfx/baker'; +import { createDefaultLightFXSettings } from './baking/lightfx/settings'; +import { BaseService, register, Service } from './core'; + +interface ProbeSnapshot { + normal: Vec3; + coefficients: Vec3[]; +} + +@register('LightProbeBake') +export class LightProbeBakeService extends BaseService implements ILightProbeBakeService { + async bake(options: ILightProbeBakeOptions = {}): Promise { + const started = Date.now(); + const scene = director.getScene() as Scene | null; + if (!scene) throw new Error('No scene is currently open.'); + + const sceneUrl = await this.querySceneUrl(); + const info: any = scene.globals.lightProbeInfo; + const probes: any[] = info.data?.probes ?? []; + if (probes.length < 4) throw new Error('At least four generated light probes are required.'); + + const giScale = options.giScale ?? info.giScale; + const giSamples = options.giSamples ?? info.giSamples; + const bounces = options.bounces ?? info.bounces; + const settings = createDefaultLightFXSettings('light-probe'); + settings.giProbeScale = giScale; + settings.giProbeSamples = giSamples; + settings.giProbePathLength = bounces; + + const previous = this.snapshot(probes); + let output: LightFXBakeOutput | undefined; + this.broadcast('lightfx:bake-start', 'light-probe'); + try { + output = await lightFXCoordinator.bake(scene, 'light-probe', settings, options.timeoutMs ?? 600_000); + this.validateResult(probes, output); + + const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Bake light probes' }); + try { + this.applyResult(probes, output); + info.onProbeBakeFinished(); + await Service.Engine.repaintInEditMode(); + if (options.saveScene !== false) await Service.Editor.save({}); + await Service.Undo.endRecording(undo); + } catch (error) { + Service.Undo.cancelRecording(undo); + throw error; + } + + this.broadcast('lightfx:bake-end', 'light-probe'); + return { sceneUrl, probeCount: probes.length, giScale, giSamples, bounces, durationMs: Date.now() - started }; + } catch (error) { + this.restore(probes, previous); + info.onProbeBakeFinished(); + await Service.Engine.repaintInEditMode(); + this.broadcast('lightfx:bake-end', 'light-probe', this.errorMessage(error)); + throw error; + } finally { + if (output) await remove(output.workspace).catch(() => undefined); + } + } + + async clearBake(options: { saveScene?: boolean } = {}): Promise<{ probeCount: number }> { + const scene = director.getScene(); + if (!scene) throw new Error('No scene is currently open.'); + const info: any = scene.globals.lightProbeInfo; + const probes: any[] = info.data?.probes ?? []; + const previous = this.snapshot(probes); + const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Clear light probes' }); + try { + info.onProbeBakeCleared(); + await Service.Engine.repaintInEditMode(); + if (options.saveScene !== false) await Service.Editor.save({}); + await Service.Undo.endRecording(undo); + return { probeCount: probes.length }; + } catch (error) { + Service.Undo.cancelRecording(undo); + this.restore(probes, previous); + info.onProbeBakeFinished(); + await Service.Engine.repaintInEditMode(); + throw error; + } + } + + cancel(): Promise { + return lightFXCoordinator.cancel(); + } + + private async querySceneUrl(): Promise { + const current = await Service.Editor.queryCurrent(); + const sceneUrl = ((current as any)?.__identifier__?.assetUrl ?? (current as any)?.assetUrl) as string | undefined; + if (!sceneUrl?.endsWith('.scene')) throw new Error('Light probes can only be baked in a saved scene asset.'); + return sceneUrl; + } + + private validateResult(probes: any[], output: LightFXBakeOutput): void { + const result = output.result.probes; + if (result.length !== probes.length) throw new Error(`LightFX returned ${result.length} probes, expected ${probes.length}.`); + const coefficientCount = SH.getBasisCount() * 3; + result.forEach((item, index) => { + if (item.coefficients.length !== coefficientCount) throw new Error(`Light probe ${index} has an invalid SH coefficient count.`); + const position = probes[index].position; + const dx = position.x - item.position[0]; + const dy = position.y - item.position[1]; + const dz = position.z - item.position[2]; + if (dx * dx + dy * dy + dz * dz > 1e-6) throw new Error(`Light probe ${index} does not match the exported scene position.`); + }); + } + + private applyResult(probes: any[], output: LightFXBakeOutput): void { + const basisCount = SH.getBasisCount(); + output.result.probes.forEach((item, index) => { + probes[index].normal.set(...item.normal); + probes[index].coefficients = Array.from({ length: basisCount }, (_, coefficient) => new Vec3( + item.coefficients[coefficient * 3], + item.coefficients[coefficient * 3 + 1], + item.coefficients[coefficient * 3 + 2], + )); + }); + } + + private snapshot(probes: any[]): ProbeSnapshot[] { + return probes.map((probe) => ({ + normal: probe.normal.clone(), + coefficients: probe.coefficients.map((coefficient: Vec3) => coefficient.clone()), + })); + } + + private restore(probes: any[], snapshot: ProbeSnapshot[]): void { + snapshot.forEach((item, index) => { + probes[index].normal.set(item.normal); + probes[index].coefficients = item.coefficients; + }); + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts new file mode 100644 index 000000000..1b334ed43 --- /dev/null +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -0,0 +1,53 @@ +import { assetManager, director, MeshRenderer, Scene, Terrain, Texture2D } from 'cc'; +import { copy, pathExists, readdir, remove } from 'fs-extra'; +import { join } from 'path'; +import type { ILightFXBakeEvents, ILightFXCancelResult, ILightmapBakeOptions, ILightmapBakeResult, ILightmapBakeService } from '../../common'; +import { BaseService, register, Service } from './core'; +import { lightFXCoordinator } from './baking/lightfx/baker'; +import type { LightFXBakeOutput } from './baking/lightfx/baker'; +import { LightmapAssetTransaction } from './baking/lightfx/asset-transaction'; +import { createDefaultLightFXSettings } from './baking/lightfx/settings'; +import { Rpc } from '../rpc'; + +@register('LightmapBake') +export class LightmapBakeService extends BaseService implements ILightmapBakeService { + async bake(options: ILightmapBakeOptions = {}): Promise { + const started = Date.now(); const scene = director.getScene() as Scene | null; if (!scene) throw new Error('No scene is currently open.'); const current = await Service.Editor.queryCurrent(); const sceneUrl = ((current as any)?.__identifier__?.assetUrl ?? (current as any)?.assetUrl) as string | undefined; if (!sceneUrl?.endsWith('.scene')) throw new Error('Lightmaps can only be baked in a saved scene asset.'); + const s = createDefaultLightFXSettings('lightmap'); Object.assign(s, { msaa: options.msaa ?? s.msaa, size: options.resolution ?? s.size, filter: options.filter ?? s.filter, highp: options.highp ?? s.highp, giScale: options.giScale ?? s.giScale, giSamples: options.giSamples ?? s.giSamples, giPathLength: options.giPathLength ?? s.giPathLength, aoLevel: options.aoLevel ?? s.aoLevel, aoStrength: options.aoStrength ?? s.aoStrength, aoRadius: options.aoRadius ?? s.aoRadius, aoColor: options.aoColor?.slice(0, 3) ?? s.aoColor, threads: options.threads ?? s.threads }); this.broadcast('lightfx:bake-start', 'lightmap'); + let output: LightFXBakeOutput | undefined; + let assets: LightmapAssetTransaction | undefined; + let refreshUrl: string | undefined; + try { + output = await lightFXCoordinator.bake(scene, 'lightmap', s, options.timeoutMs ?? 600_000); if (!output.models.length && !output.terrains.length) throw new Error('No bakeable meshes or terrains were found.'); + const assetRoot = await Rpc.getInstance().request('assetManager', 'queryPath', ['db://assets']) as string; const targetDir = join(assetRoot, scene.name, 'lightmap'); const targetUrl = `db://assets/${scene.name}/lightmap`; refreshUrl = `db://assets/${scene.name}`; + assets = new LightmapAssetTransaction(targetDir, output.workspace); await assets.prepare(); + const textureUrls: string[] = []; const outputFiles = (await readdir(output.outputDir)).filter((f) => f.toLowerCase().endsWith('.png')); for (const file of outputFiles) { await copy(join(output.outputDir, file), join(targetDir, file), { overwrite: true }); textureUrls.push(`${targetUrl}/${file}`); } + if (!outputFiles.length) throw new Error('LightFX did not produce any lightmap textures.'); + await Rpc.getInstance().request('assetManager', 'refreshAsset', [targetUrl]); const textures = new Map(); + for (const item of [...output.result.meshes, ...output.result.terrains]) { if (textures.has(item.index)) continue; const file = `LFX_Mesh_${String(item.index).padStart(4, '0')}.png`; const terrainFile = `LFX_Terrain_${String(item.index).padStart(4, '0')}.png`; const url = await pathExists(join(targetDir, file)) ? `${targetUrl}/${file}` : `${targetUrl}/${terrainFile}`; const uuid = await this.waitForAsset(url, Math.min(options.timeoutMs ?? 600_000, 60_000)); await this.disableAlphaFix(uuid); textures.set(item.index, await this.loadTexture(`${uuid}@6c48a`)); } + const modelState = output.models.map((model: any) => ({ model, texture: model.bakeSettings.texture, uv: model.bakeSettings.uvParam.clone() })); + const terrainState = output.terrains.map((terrain: any) => ({ terrain, infos: (terrain._lightmapInfos ?? []).map((info: any) => info ? ({ texture: info.texture, uv: info.uvParam?.clone?.() ?? { x: info.UOff, y: info.VOff, z: info.UScale, w: info.VScale } }) : null) })); + const oldHighp = (scene.globals as any).bakedWithHighpLightmap; + const oldStationary = (scene.globals as any).bakedWithStationaryMainLight; + const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Bake lightmap' }); try { + for (const terrain of output.terrains as any[]) if (terrain.lightMapSize > 0) terrain._resetLightmap(true); + for (const item of output.result.meshes) { const model: any = output.models[item.id]; if (!model) throw new Error(`LightFX returned invalid mesh id: ${item.id}`); model._updateLightmap(textures.get(item.index), item.offset[0], item.offset[1], item.scale[0], item.scale[1]); model.node._dirtyFlags = 1; } + for (const item of output.result.terrains) { const terrain: any = output.terrains[item.id]; if (!terrain) throw new Error(`LightFX returned invalid terrain id: ${item.id}`); terrain._updateLightmap(item.blockId, textures.get(item.index), item.offset[0], item.offset[1], item.scale[0], item.scale[1]); } + (scene.globals as any).bakedWithHighpLightmap = s.highp; (scene.globals as any).bakedWithStationaryMainLight = output.stationaryMainLight; await Service.Engine.repaintInEditMode(); if (options.saveScene !== false) await Service.Editor.save({}); await Service.Undo.endRecording(undo); + } catch (error) { + for (const item of modelState) item.model._updateLightmap(item.texture, item.uv.x, item.uv.y, item.uv.z, item.uv.w); + for (const item of terrainState) item.infos.forEach((info: any, blockId: number) => info && item.terrain._updateLightmap(blockId, info.texture, info.uv.x, info.uv.y, info.uv.z, info.uv.w)); + (scene.globals as any).bakedWithHighpLightmap = oldHighp; + (scene.globals as any).bakedWithStationaryMainLight = oldStationary; + Service.Undo.cancelRecording(undo); throw error; + } + this.broadcast('lightfx:bake-end', 'lightmap'); return { sceneUrl, textureUrls, meshCount: output.result.meshes.length, terrainCount: output.result.terrains.length, durationMs: Date.now() - started }; + } catch (error) { if (assets) { try { await assets.rollback(); if (refreshUrl) await Rpc.getInstance().request('assetManager', 'refreshAsset', [refreshUrl]); } catch (rollbackError) { console.error('[LightFX] Failed to roll back lightmap assets:', rollbackError); } } const message = error instanceof Error ? error.message : String(error); this.broadcast('lightfx:bake-end', 'lightmap', message); throw error; } + finally { if (output) await remove(output.workspace).catch(() => undefined); } + } + async clearBake(options: { saveScene?: boolean; deleteAssets?: boolean } = {}): Promise<{ clearedCount: number }> { const scene: any = director.getScene(); if (!scene) throw new Error('No scene is currently open.'); let count = 0; const visit = (node: any): void => { for (const model of node.getComponents(MeshRenderer) as MeshRenderer[]) { if (model.bakeSettings.texture) { model._updateLightmap(null, 0, 0, 0, 0); count++; } } for (const terrain of node.getComponents(Terrain) as Terrain[]) { const infos: any[] = (terrain as any)._lightmapInfos ?? []; infos.forEach((info, blockId) => { if (info.texture) { terrain._updateLightmap(blockId, null, 0, 0, 0, 0); count++; } }); } node.children.forEach(visit); }; visit(scene); scene.globals.bakedWithHighpLightmap = false; scene.globals.bakedWithStationaryMainLight = false; await Service.Engine.repaintInEditMode(); if (options.saveScene !== false) await Service.Editor.save({}); if (options.deleteAssets) { const root = await Rpc.getInstance().request('assetManager', 'queryPath', ['db://assets']) as string; await remove(join(root, scene.name, 'lightmap')); await Rpc.getInstance().request('assetManager', 'refreshAsset', [`db://assets/${scene.name}`]); } return { clearedCount: count }; } + cancel(): Promise { return lightFXCoordinator.cancel(); } + private async waitForAsset(url: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; do { const uuid = await Rpc.getInstance().request('assetManager', 'queryUUID', [url]) as string | null; if (uuid) return uuid; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() < deadline); throw new Error(`Lightmap texture import timed out: ${url}`); } + private async disableAlphaFix(uuid: string): Promise { const rpc = Rpc.getInstance(); const meta = await rpc.request('assetManager', 'queryAssetMeta', [uuid]) as any; if (meta?.userData?.fixAlphaTransparencyArtifacts === false) return; if (!meta) throw new Error(`Lightmap texture metadata is unavailable: ${uuid}`); meta.userData ??= {}; meta.userData.fixAlphaTransparencyArtifacts = false; await rpc.request('assetManager', 'saveAssetMeta', [uuid, meta]); } + private loadTexture(uuid: string): Promise { return new Promise((resolve, reject) => assetManager.loadAny(uuid, (error, asset: Texture2D) => error ? reject(error) : resolve(asset))); } +} diff --git a/src/core/scene/test/lightfx-asset-transaction.test.ts b/src/core/scene/test/lightfx-asset-transaction.test.ts new file mode 100644 index 000000000..325145efb --- /dev/null +++ b/src/core/scene/test/lightfx-asset-transaction.test.ts @@ -0,0 +1,32 @@ +import { mkdtemp, outputFile, pathExists, readFile, remove } from 'fs-extra'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { LightmapAssetTransaction } from '../scene-process/service/baking/lightfx/asset-transaction'; + +describe('LightmapAssetTransaction', () => { + let root: string; + + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'lightfx-assets-')); }); + afterEach(async () => { await remove(root); }); + + it('restores an existing lightmap directory after a failed import', async () => { + const target = join(root, 'assets', 'Scene', 'lightmap'); + await outputFile(join(target, 'old.png'), 'old'); + const transaction = new LightmapAssetTransaction(target, join(root, 'workspace')); + await transaction.prepare(); + expect(await pathExists(join(target, 'old.png'))).toBe(false); + await outputFile(join(target, 'new.png'), 'new'); + await transaction.rollback(); + expect((await readFile(join(target, 'old.png'))).toString()).toBe('old'); + expect(await pathExists(join(target, 'new.png'))).toBe(false); + }); + + it('removes a newly created lightmap directory after rollback', async () => { + const target = join(root, 'assets', 'Scene', 'lightmap'); + const transaction = new LightmapAssetTransaction(target, join(root, 'workspace')); + await transaction.prepare(); + await outputFile(join(target, 'new.png'), 'new'); + await transaction.rollback(); + expect(await pathExists(target)).toBe(false); + }); +}); diff --git a/src/core/scene/test/lightfx-format.test.ts b/src/core/scene/test/lightfx-format.test.ts new file mode 100644 index 000000000..480a9b6ea --- /dev/null +++ b/src/core/scene/test/lightfx-format.test.ts @@ -0,0 +1,34 @@ +import { LightFXBuffer } from '../scene-process/service/baking/lightfx/buffer'; +import { decodeLightFXOutput, encodeLightFXInput } from '../scene-process/service/baking/lightfx/format'; +import { LIGHTFX_FILE_VERSION, LightFXChunk, LightFXWorld } from '../scene-process/service/baking/lightfx/types'; +import { createDefaultLightFXSettings } from '../scene-process/service/baking/lightfx/settings'; + +describe('LightFX binary format', () => { + it('encodes both bake target flags and scene chunks', () => { + const world: LightFXWorld = { name: 'Scene', settings: createDefaultLightFXSettings('light-probe'), textures: [], terrains: [], meshes: [], lights: [], probes: [{ position: [1, 2, 3], normal: [0, 1, 0] }] }; + const encoded = encodeLightFXInput(world); + expect(encoded.byteLength).toBeGreaterThan(80); + expect(new DataView(encoded.buffer, encoded.byteOffset).getInt32(0, true)).toBe(LIGHTFX_FILE_VERSION); + }); + + it('decodes mesh, terrain and probe results', () => { + const b = new LightFXBuffer(); b.writeInt32(LIGHTFX_FILE_VERSION); + b.writeInt32(LightFXChunk.MESH); b.writeInt32(1); b.writeInt32(2); b.writeInt32(3); b.writeFloats([.1, .2, .3, .4]); + b.writeInt32(LightFXChunk.TERRAIN); b.writeInt32(4); b.writeInt32(1); b.writeInt32(5); b.writeInt32(6); b.writeFloats([.2, .3, .4, .5]); + b.writeInt32(LightFXChunk.LIGHT_PROBE); b.writeInt32(1); b.writeFloats([1, 2, 3, 0, 1, 0]); b.writeInt32(27); b.writeFloats(Array.from({ length: 27 }, (_, i) => i)); + b.writeInt32(LightFXChunk.EOF); + const result = decodeLightFXOutput(b.toUint8Array()); + expect(result.meshes[0]).toMatchObject({ id: 2, index: 3 }); expect(result.terrains[0]).toMatchObject({ id: 4, blockId: 5, index: 6 }); expect(result.probes[0].coefficients).toHaveLength(27); + }); + + it('accepts the legacy output version emitted by the bundled LightFX tool', () => { + const b = new LightFXBuffer(); b.writeInt32(0x2000); b.writeInt32(LightFXChunk.EOF); + expect(decodeLightFXOutput(b.toUint8Array()).version).toBe(0x2000); + }); + + it('rejects incompatible, truncated and unknown output', () => { + const version = new LightFXBuffer(); version.writeInt32(1); expect(() => decodeLightFXOutput(version.toUint8Array())).toThrow('Unsupported'); + const truncated = new LightFXBuffer(); truncated.writeInt32(LIGHTFX_FILE_VERSION); truncated.writeInt32(LightFXChunk.MESH); expect(() => decodeLightFXOutput(truncated.toUint8Array())).toThrow('truncated'); + const unknown = new LightFXBuffer(); unknown.writeInt32(LIGHTFX_FILE_VERSION); unknown.writeInt32(99); expect(() => decodeLightFXOutput(unknown.toUint8Array())).toThrow('Unknown'); + }); +}); diff --git a/tests/lightfx-bake-api.test.ts b/tests/lightfx-bake-api.test.ts new file mode 100644 index 000000000..157c460f6 --- /dev/null +++ b/tests/lightfx-bake-api.test.ts @@ -0,0 +1,16 @@ +import 'reflect-metadata'; +import { COMMON_STATUS } from '../src/api/base/schema-base'; +import { SchemaLightmapBakeOptions, SchemaLightProbeBakeOptions } from '../src/api/scene/lightfx-bake-schema'; + +const probeBake = jest.fn(); const lightmapBake = jest.fn(); +jest.mock('../src/api/decorator/decorator', () => ({ description: () => jest.fn(), param: () => jest.fn(), result: () => jest.fn(), title: () => jest.fn(), tool: () => jest.fn() })); +jest.mock('../src/core/scene', () => ({ Scene: { LightProbeBake: { bake: (...args: unknown[]) => probeBake(...args), clearBake: jest.fn(), cancel: jest.fn() }, LightmapBake: { bake: (...args: unknown[]) => lightmapBake(...args), clearBake: jest.fn(), cancel: jest.fn() } } })); +import { LightFXBakeApi } from '../src/api/scene/lightfx-bake'; + +describe('LightFX bake API', () => { + beforeEach(() => { probeBake.mockReset(); lightmapBake.mockReset(); }); + it('validates probe parameters', () => { expect(SchemaLightProbeBakeOptions.parse({ giScale: 8, giSamples: 4096, bounces: 1 })).toMatchObject({ giScale: 8 }); expect(() => SchemaLightProbeBakeOptions.parse({ giSamples: 1 })).toThrow(); expect(() => SchemaLightProbeBakeOptions.parse({ bounces: 5 })).toThrow(); }); + it('validates all Creator lightmap calculation parameters', () => { const options = { msaa: 4 as const, resolution: 1024, filter: true, highp: false, giScale: 1, giSamples: 25, giPathLength: 4, aoLevel: 0, aoStrength: .5, aoRadius: 1, aoColor: [136, 136, 136, 255] as [number, number, number, number], threads: 4 }; expect(SchemaLightmapBakeOptions.parse(options)).toEqual(options); }); + it('forwards probe bake and wraps success', async () => { const data = { sceneUrl: 'db://assets/a.scene', probeCount: 4, giScale: 1, giSamples: 64, bounces: 1, durationMs: 10 }; probeBake.mockResolvedValue(data); await expect(new LightFXBakeApi().bakeLightProbes({ saveScene: true })).resolves.toEqual({ code: COMMON_STATUS.SUCCESS, data }); expect(probeBake).toHaveBeenCalledWith({ saveScene: true }); }); + it('wraps LightFX failure', async () => { lightmapBake.mockRejectedValue(new Error('LightFX failed')); await expect(new LightFXBakeApi().bakeLightmap({})).resolves.toEqual({ code: COMMON_STATUS.FAIL, reason: 'LightFX failed' }); }); +}); From 46787052d074514bc9d43fafc415cbcdcdddff2c Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Sat, 29 Aug 2026 16:22:06 +0800 Subject: [PATCH 02/64] fix(scene): harden lightmap asset binding --- docs/dev/scene/lightfx-bake.md | 67 ++-- .../baking/lightfx/asset-transaction.ts | 7 + .../scene-process/service/lightmap-bake.ts | 330 ++++++++++++++++-- .../test/lightfx-asset-transaction.test.ts | 3 + 4 files changed, 326 insertions(+), 81 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 7f2bd31fe..e1b249ebd 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -7,11 +7,11 @@ Cocos CLI 需要接入以下两种离线烘焙能力: - Light Probe Bake:计算场景中光照探针的球谐光照系数(SH coefficients),将结果写回场景全局数据。 - Lightmap Bake:生成场景静态模型和地形使用的 Lightmap 纹理,导入 Asset DB 后绑定到对应组件。 -两种能力都使用 Creator 的 LightFX 工具,并共享场景导出、二进制协议、外部进程管理和结果解析。实现采用“一套公共 LightFX 内核、两个独立业务服务和两个独立 MCP 工具”的结构。 +两种能力都使用 Creator 的 LightFX 工具,并共享场景导出、二进制协议、外部进程管理和结果解析。实现采用“一套公共 LightFX 内核、两个独立业务服务和独立 MCP 工具”的结构。 本设计的目标是: -1. 第一阶段完成 Light Probe Bake 时即建立可供 Lightmap 复用的基础设施。 +1. Light Probe Bake 与 Lightmap Bake 共享同一套 LightFX 基础设施。 2. 两种烘焙可以独立调用、独立失败和独立回滚。 3. 不直接复制 Creator 中同时混合面板、Metrics、Lightmap 和 Light Probe 的大文件。 4. 保持烘焙输入、LightFX 协议和结果应用逻辑与 Creator 兼容。 @@ -19,7 +19,7 @@ Cocos CLI 需要接入以下两种离线烘焙能力: 非目标: -- 第一阶段不提供一个同时烘焙 Light Probe 和 Lightmap 的公开 `both` 接口。 +- 不提供同时烘焙 Light Probe 和 Lightmap 的公开 `both` 接口;调用方按需分别调用两个工具。 - MCP 可以可选覆盖真正参与计算的烘焙参数;未传参数时读取场景或项目现有配置。编辑器可视化参数不混入 Bake 接口。 - 不实现新的 LightFX 算法,也不修改引擎的光照探针或 Lightmap 数据结构。 - 不要求浏览器 `/scene-editor/` 提供 WebGL 捕获能力。 @@ -151,7 +151,7 @@ src/api/scene/lightmap.ts type LightFXBakeTarget = 'light-probe' | 'lightmap'; ``` -内部数据结构可以为未来组合执行保留两个布尔位,但第一阶段不公开 `both`,也不让一个业务服务同时提交两类结果。 +内部协议保留两个烘焙目标位,但不公开 `both`,也不让一个业务服务同时提交两类结果。 ### 4.2 场景导出 @@ -218,7 +218,7 @@ type LightFXBakeTarget = 'light-probe' | 'lightmap'; - 成功后默认清理临时输入;Lightmap 输出完成资产提交后再清理。 - 失败、取消和超时均执行 finally 清理。 -- 可增加内部 `keepTemporaryFiles` 调试开关,但不作为首版 MCP 参数。 +- 临时文件不作为 MCP 输出;成功、失败、取消和超时均由服务清理自身 workspace。 - 不删除 operation-id 目录以外的任何文件。 ### 4.6 LightFX 进程生命周期 @@ -261,7 +261,7 @@ type LightFXBakeStage = | 'completed'; ``` -业务服务可广播内部进度事件。MCP 首版仍等待最终结果,不依赖 Inspector 对通知的展示能力。 +业务服务广播内部进度事件;MCP 调用等待最终结果,不依赖 Inspector 对通知的展示能力。 ## 5. Light Probe Bake @@ -273,7 +273,7 @@ type LightFXBakeStage = scene-bake-light-probes ``` -建议参数: +调用参数: ```ts interface ILightProbeBakeOptions { @@ -371,13 +371,13 @@ Light Probe 结果直接序列化在 `.scene` 中,不创建新的 Asset DB 资 ### 5.4 清除接口 -清除烘焙结果可作为后续独立工具: +清除烘焙结果使用独立工具: ```text scene-clear-light-probes ``` -其语义应调用 `lightProbeInfo.onProbeBakeCleared()`,进入 Undo,并按需保存场景。首个 Bake PR 不必同时实现。 +该工具调用 `lightProbeInfo.onProbeBakeCleared()`,进入 Undo,并按需保存场景。 ## 6. Lightmap Bake @@ -389,7 +389,7 @@ scene-clear-light-probes scene-bake-lightmap ``` -建议参数: +调用参数: ```ts interface ILightmapBakeOptions { @@ -407,15 +407,13 @@ interface ILightmapBakeOptions { threads?: number; saveScene?: boolean; timeoutMs?: number; - outputDir?: string; } ``` - `msaa`、`resolution`、`filter`、`highp`、GI、AO 和 `threads` 都会影响 LightFX 计算,允许 MCP 对本次烘焙进行可选覆盖。 -- 未传入的参数读取项目现有 Lightmap 配置;项目配置也不存在时才使用与 Creator 一致的默认值。 +- 未传入的参数使用与 Creator 面板初始值一致的 CLI 默认值。 - MCP 覆盖值默认只作用于本次烘焙,不写回项目 Lightmap 配置。永久修改配置应使用独立配置接口。 -- 第一版可以暂不开放 `outputDir`,统一输出到场景对应目录;若开放,只接受 `db://assets` 下的目录。 -- 不允许传入任意绝对输出路径。 +- 不开放 `outputDir`,统一输出到场景对应目录,不允许传入任意文件系统路径。 面板参数映射: @@ -455,7 +453,7 @@ MCP JSON 示例: } ``` -返回值建议包含: +返回值包含: ```ts interface ILightmapBakeResult { @@ -534,13 +532,13 @@ Lightmap 同时修改文件资产和场景,事务边界为: ### 6.6 清除接口 -后续可增加: +清理工具: ```text scene-clear-lightmap ``` -清除应解除组件绑定并更新 globals。是否删除磁盘纹理由显式参数控制,默认只解除绑定,避免破坏被其他场景引用的资源。 +`scene-clear-lightmap` 解除组件绑定并更新 globals。是否删除磁盘纹理由 `deleteAssets` 显式控制,默认只解除绑定,避免破坏被其他场景引用的资源。 ## 7. 进程与运行环境边界 @@ -564,7 +562,7 @@ LightFX 烘焙不同于 Reflection Probe 捕获: - 取消应同时终止 LightFX、关闭 Socket.IO、停止结果提交并清理 workspace。 - 一旦进入结果提交阶段,取消按失败处理并执行事务回滚。 -首版可以只提供内部取消能力;后续再增加公开的 `scene-cancel-lightfx-bake`,同时返回被取消任务的类型和 operation id。 +公开的 `scene-cancel-lightfx-bake` 可取消当前任务,并返回是否取消成功及任务类型。 ## 9. 错误模型 @@ -635,33 +633,14 @@ LightFX 烘焙不同于 Reflection Probe 捕获: - Lightmap 纹理引用有效,模型和 Terrain 显示正确。 - 场景和资产目录没有 staging、backup 或失效 meta 残留。 -## 11. 分阶段交付 +## 11. 已实现能力与验收标准 -### 阶段一:公共内核与 Light Probe Bake +- 公共 LightFX 场景导出、二进制协议、进程管理、超时、取消和 workspace 清理。 +- `scene-bake-light-probes` 与 `scene-clear-light-probes`,包括 SH 回填、Undo、失败恢复和场景保存。 +- `scene-bake-lightmap` 与 `scene-clear-lightmap`,包括 PNG 导入、meta/UUID 复用、Mesh/Terrain 独立绑定、Undo、失败恢复和可选资源删除。 +- `scene-cancel-lightfx-bake`,用于取消当前 LightFX 任务。 -- LightFX 数据类型和二进制协议。 -- Scene exporter 和 texture resolver。 -- LightFX 进程、workspace、超时和取消。 -- Light Probe MCP/Service、SH 回填、Undo 和保存。 -- 协议测试、服务测试和真实场景验证。 - -阶段一验收条件:同一场景在 Creator 与 CLI 烘焙后探针数量、系数结构和运行时光照表现一致;失败与重复烘焙不破坏旧数据。 - -### 阶段二:Lightmap Bake - -- 扩展公共 exporter 的 Lightmap 专用数据。 -- Lightmap 输出解析、图片事务和 Asset DB 导入。 -- MeshRenderer/Terrain 绑定、globals、Undo 和保存。 -- 重烘焙与失败回滚验证。 - -阶段二验收条件:基础 Mesh/Terrain 场景可在 CLI 完整烘焙,重新打开后纹理与组件引用保持有效。 - -### 阶段三:完善能力 - -- 公开取消接口。 -- Light Probe/Lightmap 清除接口。 -- 更多材质、灯光和平台兼容。 -- 根据实际需求评估组合烘焙入口和进度查询接口。 +提交验收要求:Light Probe、Mesh Lightmap 和 Terrain Lightmap 的 Bake/Clear 均通过真实场景验证;重复烘焙不改变已有贴图 UUID;重新打开场景后数据与资源引用仍有效;编译、协议测试、API 测试及资产事务测试通过。 ## 12. 实现约束与评审重点 @@ -671,4 +650,4 @@ LightFX 烘焙不同于 Reflection Probe 捕获: - 不在循环内反复创建 Undo snapshot;一次烘焙只形成一个业务操作。 - 所有外部进程、Socket.IO 服务和临时目录必须有确定的 finally 清理路径。 - 所有最终文件替换必须可回滚,不能先删除旧资产再尝试导入新资产。 -- 第一阶段新增公共接口时,要用 Lightmap 场景验证其模型索引、纹理解析和结果结构是否足够,避免第二阶段推翻公共层。 +- 公共接口变更必须同时验证 Light Probe 与包含 Mesh/Terrain 的 Lightmap 场景。 diff --git a/src/core/scene/scene-process/service/baking/lightfx/asset-transaction.ts b/src/core/scene/scene-process/service/baking/lightfx/asset-transaction.ts index 39f4518f0..728b06e25 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/asset-transaction.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/asset-transaction.ts @@ -26,4 +26,11 @@ export class LightmapAssetTransaction { await copy(this.backupDir, this.targetDir); } } + + async preserveMeta(relativeAssetPath: string): Promise { + if (!this.hadTarget) return; + const metaPath = `${relativeAssetPath}.meta`; + const source = join(this.backupDir, metaPath); + if (await pathExists(source)) await copy(source, join(this.targetDir, metaPath)); + } } diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index 1b334ed43..a9662077a 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -1,53 +1,309 @@ import { assetManager, director, MeshRenderer, Scene, Terrain, Texture2D } from 'cc'; -import { copy, pathExists, readdir, remove } from 'fs-extra'; +import { copy, readdir, remove } from 'fs-extra'; import { join } from 'path'; -import type { ILightFXBakeEvents, ILightFXCancelResult, ILightmapBakeOptions, ILightmapBakeResult, ILightmapBakeService } from '../../common'; -import { BaseService, register, Service } from './core'; +import type { + ILightFXBakeEvents, ILightFXCancelResult, ILightmapBakeOptions, + ILightmapBakeResult, ILightmapBakeService, +} from '../../common'; +import { Rpc } from '../rpc'; +import { LightmapAssetTransaction } from './baking/lightfx/asset-transaction'; import { lightFXCoordinator } from './baking/lightfx/baker'; import type { LightFXBakeOutput } from './baking/lightfx/baker'; -import { LightmapAssetTransaction } from './baking/lightfx/asset-transaction'; import { createDefaultLightFXSettings } from './baking/lightfx/settings'; -import { Rpc } from '../rpc'; +import { BaseService, register, Service } from './core'; + +interface LightmapBinding { + target: any; + blockId?: number; + texture: Texture2D | null; + uv: { x: number; y: number; z: number; w: number }; +} @register('LightmapBake') export class LightmapBakeService extends BaseService implements ILightmapBakeService { async bake(options: ILightmapBakeOptions = {}): Promise { - const started = Date.now(); const scene = director.getScene() as Scene | null; if (!scene) throw new Error('No scene is currently open.'); const current = await Service.Editor.queryCurrent(); const sceneUrl = ((current as any)?.__identifier__?.assetUrl ?? (current as any)?.assetUrl) as string | undefined; if (!sceneUrl?.endsWith('.scene')) throw new Error('Lightmaps can only be baked in a saved scene asset.'); - const s = createDefaultLightFXSettings('lightmap'); Object.assign(s, { msaa: options.msaa ?? s.msaa, size: options.resolution ?? s.size, filter: options.filter ?? s.filter, highp: options.highp ?? s.highp, giScale: options.giScale ?? s.giScale, giSamples: options.giSamples ?? s.giSamples, giPathLength: options.giPathLength ?? s.giPathLength, aoLevel: options.aoLevel ?? s.aoLevel, aoStrength: options.aoStrength ?? s.aoStrength, aoRadius: options.aoRadius ?? s.aoRadius, aoColor: options.aoColor?.slice(0, 3) ?? s.aoColor, threads: options.threads ?? s.threads }); this.broadcast('lightfx:bake-start', 'lightmap'); + const started = Date.now(); + const scene = director.getScene() as Scene | null; + if (!scene) throw new Error('No scene is currently open.'); + + const sceneUrl = await this.querySceneUrl(); + const settings = createDefaultLightFXSettings('lightmap'); + Object.assign(settings, { + msaa: options.msaa ?? settings.msaa, + size: options.resolution ?? settings.size, + filter: options.filter ?? settings.filter, + highp: options.highp ?? settings.highp, + giScale: options.giScale ?? settings.giScale, + giSamples: options.giSamples ?? settings.giSamples, + giPathLength: options.giPathLength ?? settings.giPathLength, + aoLevel: options.aoLevel ?? settings.aoLevel, + aoStrength: options.aoStrength ?? settings.aoStrength, + aoRadius: options.aoRadius ?? settings.aoRadius, + aoColor: options.aoColor?.slice(0, 3) ?? settings.aoColor, + threads: options.threads ?? settings.threads, + }); + + const timeoutMs = options.timeoutMs ?? 600_000; let output: LightFXBakeOutput | undefined; let assets: LightmapAssetTransaction | undefined; let refreshUrl: string | undefined; + this.broadcast('lightfx:bake-start', 'lightmap'); try { - output = await lightFXCoordinator.bake(scene, 'lightmap', s, options.timeoutMs ?? 600_000); if (!output.models.length && !output.terrains.length) throw new Error('No bakeable meshes or terrains were found.'); - const assetRoot = await Rpc.getInstance().request('assetManager', 'queryPath', ['db://assets']) as string; const targetDir = join(assetRoot, scene.name, 'lightmap'); const targetUrl = `db://assets/${scene.name}/lightmap`; refreshUrl = `db://assets/${scene.name}`; - assets = new LightmapAssetTransaction(targetDir, output.workspace); await assets.prepare(); - const textureUrls: string[] = []; const outputFiles = (await readdir(output.outputDir)).filter((f) => f.toLowerCase().endsWith('.png')); for (const file of outputFiles) { await copy(join(output.outputDir, file), join(targetDir, file), { overwrite: true }); textureUrls.push(`${targetUrl}/${file}`); } - if (!outputFiles.length) throw new Error('LightFX did not produce any lightmap textures.'); - await Rpc.getInstance().request('assetManager', 'refreshAsset', [targetUrl]); const textures = new Map(); - for (const item of [...output.result.meshes, ...output.result.terrains]) { if (textures.has(item.index)) continue; const file = `LFX_Mesh_${String(item.index).padStart(4, '0')}.png`; const terrainFile = `LFX_Terrain_${String(item.index).padStart(4, '0')}.png`; const url = await pathExists(join(targetDir, file)) ? `${targetUrl}/${file}` : `${targetUrl}/${terrainFile}`; const uuid = await this.waitForAsset(url, Math.min(options.timeoutMs ?? 600_000, 60_000)); await this.disableAlphaFix(uuid); textures.set(item.index, await this.loadTexture(`${uuid}@6c48a`)); } - const modelState = output.models.map((model: any) => ({ model, texture: model.bakeSettings.texture, uv: model.bakeSettings.uvParam.clone() })); - const terrainState = output.terrains.map((terrain: any) => ({ terrain, infos: (terrain._lightmapInfos ?? []).map((info: any) => info ? ({ texture: info.texture, uv: info.uvParam?.clone?.() ?? { x: info.UOff, y: info.VOff, z: info.UScale, w: info.VScale } }) : null) })); - const oldHighp = (scene.globals as any).bakedWithHighpLightmap; - const oldStationary = (scene.globals as any).bakedWithStationaryMainLight; - const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Bake lightmap' }); try { - for (const terrain of output.terrains as any[]) if (terrain.lightMapSize > 0) terrain._resetLightmap(true); - for (const item of output.result.meshes) { const model: any = output.models[item.id]; if (!model) throw new Error(`LightFX returned invalid mesh id: ${item.id}`); model._updateLightmap(textures.get(item.index), item.offset[0], item.offset[1], item.scale[0], item.scale[1]); model.node._dirtyFlags = 1; } - for (const item of output.result.terrains) { const terrain: any = output.terrains[item.id]; if (!terrain) throw new Error(`LightFX returned invalid terrain id: ${item.id}`); terrain._updateLightmap(item.blockId, textures.get(item.index), item.offset[0], item.offset[1], item.scale[0], item.scale[1]); } - (scene.globals as any).bakedWithHighpLightmap = s.highp; (scene.globals as any).bakedWithStationaryMainLight = output.stationaryMainLight; await Service.Engine.repaintInEditMode(); if (options.saveScene !== false) await Service.Editor.save({}); await Service.Undo.endRecording(undo); + output = await lightFXCoordinator.bake(scene, 'lightmap', settings, timeoutMs); + if (!output.models.length && !output.terrains.length) { + throw new Error('No bakeable meshes or terrains were found.'); + } + + const assetRoot = await Rpc.getInstance().request('assetManager', 'queryPath', ['db://assets']) as string; + const targetDir = join(assetRoot, scene.name, 'lightmap'); + const targetUrl = `db://assets/${scene.name}/lightmap`; + refreshUrl = `db://assets/${scene.name}`; + assets = new LightmapAssetTransaction(targetDir, output.workspace); + await assets.prepare(); + + const textureUrls = await this.importOutputTextures(output, assets, targetDir, targetUrl); + const textures = await this.loadOutputTextures(output, targetUrl, timeoutMs); + const previousBindings = this.snapshotBindings(output); + const previousHighp = (scene.globals as any).bakedWithHighpLightmap; + const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; + const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Bake lightmap' }); + try { + this.applyBakeResult(output, textures); + (scene.globals as any).bakedWithHighpLightmap = settings.highp; + (scene.globals as any).bakedWithStationaryMainLight = output.stationaryMainLight; + await Service.Engine.repaintInEditMode(); + if (options.saveScene !== false) await Service.Editor.save({}); + await Service.Undo.endRecording(undo); } catch (error) { - for (const item of modelState) item.model._updateLightmap(item.texture, item.uv.x, item.uv.y, item.uv.z, item.uv.w); - for (const item of terrainState) item.infos.forEach((info: any, blockId: number) => info && item.terrain._updateLightmap(blockId, info.texture, info.uv.x, info.uv.y, info.uv.z, info.uv.w)); - (scene.globals as any).bakedWithHighpLightmap = oldHighp; - (scene.globals as any).bakedWithStationaryMainLight = oldStationary; - Service.Undo.cancelRecording(undo); throw error; + this.restoreBindings(previousBindings); + (scene.globals as any).bakedWithHighpLightmap = previousHighp; + (scene.globals as any).bakedWithStationaryMainLight = previousStationary; + Service.Undo.cancelRecording(undo); + throw error; + } + + this.broadcast('lightfx:bake-end', 'lightmap'); + return { + sceneUrl, + textureUrls, + meshCount: output.result.meshes.length, + terrainCount: output.result.terrains.length, + durationMs: Date.now() - started, + }; + } catch (error) { + if (assets) { + try { + await assets.rollback(); + if (refreshUrl) await Rpc.getInstance().request('assetManager', 'refreshAsset', [refreshUrl]); + } catch (rollbackError) { + console.error('[LightFX] Failed to roll back lightmap assets:', rollbackError); + } + } + this.broadcast('lightfx:bake-end', 'lightmap', this.errorMessage(error)); + throw error; + } finally { + if (output) await remove(output.workspace).catch(() => undefined); + } + } + + async clearBake(options: { saveScene?: boolean; deleteAssets?: boolean } = {}): Promise<{ clearedCount: number }> { + const scene = director.getScene() as Scene | null; + if (!scene) throw new Error('No scene is currently open.'); + + const bindings = this.snapshotSceneBindings(scene); + const previousHighp = (scene.globals as any).bakedWithHighpLightmap; + const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; + const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Clear lightmap' }); + try { + this.clearBindings(bindings); + (scene.globals as any).bakedWithHighpLightmap = false; + (scene.globals as any).bakedWithStationaryMainLight = false; + await Service.Engine.repaintInEditMode(); + if (options.saveScene !== false) await Service.Editor.save({}); + await Service.Undo.endRecording(undo); + } catch (error) { + Service.Undo.cancelRecording(undo); + this.restoreBindings(bindings); + (scene.globals as any).bakedWithHighpLightmap = previousHighp; + (scene.globals as any).bakedWithStationaryMainLight = previousStationary; + await Service.Engine.repaintInEditMode(); + throw error; + } + + if (options.deleteAssets) { + const root = await Rpc.getInstance().request('assetManager', 'queryPath', ['db://assets']) as string; + await remove(join(root, scene.name, 'lightmap')); + await Rpc.getInstance().request('assetManager', 'refreshAsset', [`db://assets/${scene.name}`]); + } + return { clearedCount: bindings.length }; + } + + cancel(): Promise { + return lightFXCoordinator.cancel(); + } + + private async querySceneUrl(): Promise { + const current = await Service.Editor.queryCurrent(); + const sceneUrl = ((current as any)?.__identifier__?.assetUrl ?? (current as any)?.assetUrl) as string | undefined; + if (!sceneUrl?.endsWith('.scene')) throw new Error('Lightmaps can only be baked in a saved scene asset.'); + return sceneUrl; + } + + private async importOutputTextures( + output: LightFXBakeOutput, + assets: LightmapAssetTransaction, + targetDir: string, + targetUrl: string, + ): Promise { + const files = (await readdir(output.outputDir)).filter((file) => file.toLowerCase().endsWith('.png')); + if (!files.length) throw new Error('LightFX did not produce any lightmap textures.'); + for (const file of files) { + await copy(join(output.outputDir, file), join(targetDir, file), { overwrite: true }); + await assets.preserveMeta(file); + } + await Rpc.getInstance().request('assetManager', 'refreshAsset', [targetUrl]); + return files.map((file) => `${targetUrl}/${file}`); + } + + private async loadOutputTextures( + output: LightFXBakeOutput, + targetUrl: string, + timeoutMs: number, + ): Promise> { + const textures = new Map(); + for (const item of output.result.meshes) { + await this.loadIndexedTexture(textures, 'mesh', item.index, targetUrl, timeoutMs); + } + for (const item of output.result.terrains) { + await this.loadIndexedTexture(textures, 'terrain', item.index, targetUrl, timeoutMs); + } + return textures; + } + + private async loadIndexedTexture( + textures: Map, kind: 'mesh' | 'terrain', index: number, + targetUrl: string, timeoutMs: number, + ): Promise { + const key = `${kind}:${index}`; + if (textures.has(key)) return; + const prefix = kind === 'mesh' ? 'Mesh' : 'Terrain'; + const file = `LFX_${prefix}_${String(index).padStart(4, '0')}.png`; + const uuid = await this.waitForAsset(`${targetUrl}/${file}`, Math.min(timeoutMs, 60_000)); + await this.disableAlphaFix(uuid); + textures.set(key, await this.loadTexture(`${uuid}@6c48a`)); + } + + private applyBakeResult(output: LightFXBakeOutput, textures: Map): void { + for (const terrain of output.terrains as any[]) { + if (terrain.lightMapSize > 0) terrain._resetLightmap(true); + } + for (const item of output.result.meshes) { + const model: any = output.models[item.id]; + if (!model) throw new Error(`LightFX returned invalid mesh id: ${item.id}`); + model._updateLightmap( + textures.get(`mesh:${item.index}`), + item.offset[0], item.offset[1], item.scale[0], item.scale[1], + ); + model.node._dirtyFlags = 1; + } + for (const item of output.result.terrains) { + const terrain: any = output.terrains[item.id]; + if (!terrain) throw new Error(`LightFX returned invalid terrain id: ${item.id}`); + terrain._updateLightmap( + item.blockId, textures.get(`terrain:${item.index}`), + item.offset[0], item.offset[1], item.scale[0], item.scale[1], + ); + } + } + + private snapshotBindings(output: LightFXBakeOutput): LightmapBinding[] { + return [ + ...output.models.map((model: any) => ({ + target: model, + texture: model.bakeSettings.texture, + uv: model.bakeSettings.uvParam.clone(), + })), + ...this.snapshotTerrainBindings(output.terrains), + ]; + } + + private snapshotSceneBindings(scene: Scene): LightmapBinding[] { + const bindings: LightmapBinding[] = []; + const visit = (node: any): void => { + for (const model of node.getComponents(MeshRenderer) as any[]) { + if (model.bakeSettings.texture) { + bindings.push({ + target: model, + texture: model.bakeSettings.texture, + uv: model.bakeSettings.uvParam.clone(), + }); + } } - this.broadcast('lightfx:bake-end', 'lightmap'); return { sceneUrl, textureUrls, meshCount: output.result.meshes.length, terrainCount: output.result.terrains.length, durationMs: Date.now() - started }; - } catch (error) { if (assets) { try { await assets.rollback(); if (refreshUrl) await Rpc.getInstance().request('assetManager', 'refreshAsset', [refreshUrl]); } catch (rollbackError) { console.error('[LightFX] Failed to roll back lightmap assets:', rollbackError); } } const message = error instanceof Error ? error.message : String(error); this.broadcast('lightfx:bake-end', 'lightmap', message); throw error; } - finally { if (output) await remove(output.workspace).catch(() => undefined); } - } - async clearBake(options: { saveScene?: boolean; deleteAssets?: boolean } = {}): Promise<{ clearedCount: number }> { const scene: any = director.getScene(); if (!scene) throw new Error('No scene is currently open.'); let count = 0; const visit = (node: any): void => { for (const model of node.getComponents(MeshRenderer) as MeshRenderer[]) { if (model.bakeSettings.texture) { model._updateLightmap(null, 0, 0, 0, 0); count++; } } for (const terrain of node.getComponents(Terrain) as Terrain[]) { const infos: any[] = (terrain as any)._lightmapInfos ?? []; infos.forEach((info, blockId) => { if (info.texture) { terrain._updateLightmap(blockId, null, 0, 0, 0, 0); count++; } }); } node.children.forEach(visit); }; visit(scene); scene.globals.bakedWithHighpLightmap = false; scene.globals.bakedWithStationaryMainLight = false; await Service.Engine.repaintInEditMode(); if (options.saveScene !== false) await Service.Editor.save({}); if (options.deleteAssets) { const root = await Rpc.getInstance().request('assetManager', 'queryPath', ['db://assets']) as string; await remove(join(root, scene.name, 'lightmap')); await Rpc.getInstance().request('assetManager', 'refreshAsset', [`db://assets/${scene.name}`]); } return { clearedCount: count }; } - cancel(): Promise { return lightFXCoordinator.cancel(); } - private async waitForAsset(url: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; do { const uuid = await Rpc.getInstance().request('assetManager', 'queryUUID', [url]) as string | null; if (uuid) return uuid; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() < deadline); throw new Error(`Lightmap texture import timed out: ${url}`); } - private async disableAlphaFix(uuid: string): Promise { const rpc = Rpc.getInstance(); const meta = await rpc.request('assetManager', 'queryAssetMeta', [uuid]) as any; if (meta?.userData?.fixAlphaTransparencyArtifacts === false) return; if (!meta) throw new Error(`Lightmap texture metadata is unavailable: ${uuid}`); meta.userData ??= {}; meta.userData.fixAlphaTransparencyArtifacts = false; await rpc.request('assetManager', 'saveAssetMeta', [uuid, meta]); } - private loadTexture(uuid: string): Promise { return new Promise((resolve, reject) => assetManager.loadAny(uuid, (error, asset: Texture2D) => error ? reject(error) : resolve(asset))); } + bindings.push(...this.snapshotTerrainBindings(node.getComponents(Terrain))); + node.children.forEach(visit); + }; + visit(scene); + return bindings; + } + + private snapshotTerrainBindings(terrains: readonly any[]): LightmapBinding[] { + const bindings: LightmapBinding[] = []; + for (const terrain of terrains) { + ((terrain._lightmapInfos ?? []) as any[]).forEach((info, blockId) => { + if (!info?.texture) return; + bindings.push({ + target: terrain, + blockId, + texture: info.texture, + uv: info.uvParam?.clone?.() ?? { x: info.UOff, y: info.VOff, z: info.UScale, w: info.VScale }, + }); + }); + } + return bindings; + } + + private clearBindings(bindings: LightmapBinding[]): void { + for (const binding of bindings) { + if (binding.blockId === undefined) binding.target._updateLightmap(null, 0, 0, 0, 0); + else binding.target._updateLightmap(binding.blockId, null, 0, 0, 0, 0); + } + } + + private restoreBindings(bindings: LightmapBinding[]): void { + for (const binding of bindings) { + const { x, y, z, w } = binding.uv; + if (binding.blockId === undefined) binding.target._updateLightmap(binding.texture, x, y, z, w); + else binding.target._updateLightmap(binding.blockId, binding.texture, x, y, z, w); + } + } + + private async waitForAsset(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + do { + const uuid = await Rpc.getInstance().request('assetManager', 'queryUUID', [url]) as string | null; + if (uuid) return uuid; + await new Promise((resolve) => setTimeout(resolve, 200)); + } while (Date.now() < deadline); + throw new Error(`Lightmap texture import timed out: ${url}`); + } + + private async disableAlphaFix(uuid: string): Promise { + const rpc = Rpc.getInstance(); + const meta = await rpc.request('assetManager', 'queryAssetMeta', [uuid]) as any; + if (meta?.userData?.fixAlphaTransparencyArtifacts === false) return; + if (!meta) throw new Error(`Lightmap texture metadata is unavailable: ${uuid}`); + meta.userData ??= {}; + meta.userData.fixAlphaTransparencyArtifacts = false; + await rpc.request('assetManager', 'saveAssetMeta', [uuid, meta]); + } + + private loadTexture(uuid: string): Promise { + return new Promise((resolve, reject) => { + assetManager.loadAny(uuid, (error, asset: Texture2D) => error ? reject(error) : resolve(asset)); + }); + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } } diff --git a/src/core/scene/test/lightfx-asset-transaction.test.ts b/src/core/scene/test/lightfx-asset-transaction.test.ts index 325145efb..d7ae2d18a 100644 --- a/src/core/scene/test/lightfx-asset-transaction.test.ts +++ b/src/core/scene/test/lightfx-asset-transaction.test.ts @@ -12,9 +12,12 @@ describe('LightmapAssetTransaction', () => { it('restores an existing lightmap directory after a failed import', async () => { const target = join(root, 'assets', 'Scene', 'lightmap'); await outputFile(join(target, 'old.png'), 'old'); + await outputFile(join(target, 'old.png.meta'), 'meta'); const transaction = new LightmapAssetTransaction(target, join(root, 'workspace')); await transaction.prepare(); expect(await pathExists(join(target, 'old.png'))).toBe(false); + await transaction.preserveMeta('old.png'); + expect((await readFile(join(target, 'old.png.meta'))).toString()).toBe('meta'); await outputFile(join(target, 'new.png'), 'new'); await transaction.rollback(); expect((await readFile(join(target, 'old.png'))).toString()).toBe('old'); From e0cc2ff37d1a55ff1cccc371444340454546f0c9 Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Mon, 31 Aug 2026 11:42:42 +0800 Subject: [PATCH 03/64] update doc --- docs/dev/scene/lightfx-bake.md | 727 ++++++++------------------------- 1 file changed, 170 insertions(+), 557 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index e1b249ebd..d30218f5a 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -1,307 +1,29 @@ -# LightFX 烘焙能力接入设计 +# Light Probe 与 Lightmap 烘焙 -## 1. 背景与目标 +## 功能概览 -Cocos CLI 需要接入以下两种离线烘焙能力: +Cocos CLI 通过 Creator 随附的 LightFX 工具提供离线光照烘焙能力: -- Light Probe Bake:计算场景中光照探针的球谐光照系数(SH coefficients),将结果写回场景全局数据。 -- Lightmap Bake:生成场景静态模型和地形使用的 Lightmap 纹理,导入 Asset DB 后绑定到对应组件。 +- Light Probe:计算场景内所有有效光照探针的球谐光照系数,并写回场景。 +- Lightmap:为静态 Mesh 和 Terrain 生成 Lightmap,导入 Asset DB 并绑定到组件。 +- 清理:解除 Light Probe 或 Lightmap 的烘焙结果,可选择保存场景及删除 Lightmap 资产。 +- 取消:终止当前正在运行的 LightFX 任务。 -两种能力都使用 Creator 的 LightFX 工具,并共享场景导出、二进制协议、外部进程管理和结果解析。实现采用“一套公共 LightFX 内核、两个独立业务服务和独立 MCP 工具”的结构。 +MCP API 只负责参数校验和结果封装。场景数据读取、LightFX 调用、结果绑定、Undo、保存和回滚均在 scene-process 服务中完成。Light Probe 与 Lightmap 共享场景导出、二进制协议、进程管理和临时目录管理。 -本设计的目标是: +## 使用前提 -1. Light Probe Bake 与 Lightmap Bake 共享同一套 LightFX 基础设施。 -2. 两种烘焙可以独立调用、独立失败和独立回滚。 -3. 不直接复制 Creator 中同时混合面板、Metrics、Lightmap 和 Light Probe 的大文件。 -4. 保持烘焙输入、LightFX 协议和结果应用逻辑与 Creator 兼容。 -5. 支持 MCP、CLI 场景服务以及未来 VSCode/Pink 场景编辑器调用。 +1. 使用 CLI 打开一个已保存的 `.scene` 资产;不支持未保存场景和 prefab。 +2. Light Probe 烘焙前,场景中需要至少 4 个已生成的有效探针。 +3. Lightmap 烘焙前,需要在 MeshRenderer、SkinnedMeshRenderer 或 Terrain 上配置有效的烘焙设置。 +4. 同一时间只允许运行一个 LightFX 烘焙任务。 +5. LightFX 在 Node scene-process 中执行,不要求打开浏览器 `/scene-editor/`,也不依赖 WebGL 场景渲染器。 -非目标: +## MCP 工具 -- 不提供同时烘焙 Light Probe 和 Lightmap 的公开 `both` 接口;调用方按需分别调用两个工具。 -- MCP 可以可选覆盖真正参与计算的烘焙参数;未传参数时读取场景或项目现有配置。编辑器可视化参数不混入 Bake 接口。 -- 不实现新的 LightFX 算法,也不修改引擎的光照探针或 Lightmap 数据结构。 -- 不要求浏览器 `/scene-editor/` 提供 WebGL 捕获能力。 +### 烘焙 Light Probe -## 2. Creator 现有流程 - -### 2.1 Light Probe - -Creator 的调用链为: - -```text -Light Probe 面板 - -> 获取 Lightmap 配置并设置 temp/light-probe 输出目录 - -> lightmap 扩展的 bakeLightProbe - -> scene process 中导出场景 - -> 写入 tmp/lfx.in - -> 启动本地 Socket.IO 服务和 LightFX 进程 - -> LightFX 输出 output/lfx.out - -> 解析 Position、Normal、SH coefficients - -> 写回 scene.globals.lightProbeInfo.data.probes - -> lightProbeInfo.onProbeBakeFinished() - -> repaint、记录场景修改 -``` - -相关 Creator 代码: - -- `app/modules/editor-extensions/extensions/light-probe/source/renderer.ts` -- `app/modules/editor-extensions/extensions/lightmap/source/lightmap/index.ts` -- `app/modules/editor-extensions/extensions/lightmap/source/lightmap/backer/LFX_App.ts` -- `app/modules/editor-extensions/extensions/lightmap/source/lightmap/backer/LFX_Baker.ts` -- `app/modules/editor-extensions/extensions/lightmap/source/lightmap/backer/LFX_Types.ts` - -### 2.2 Lightmap - -Lightmap 与 Light Probe 使用相同的场景导出和 LightFX 进程。区别在于结果包含纹理以及模型、地形对应的 UV offset/scale。Creator 在完成后还会: - -- 刷新并导入输出 PNG。 -- 修改图片 meta,使其作为纹理导入。 -- 加载 Texture2D 子资源。 -- 调用 MeshRenderer/Terrain 的 Lightmap 更新接口。 -- 更新 `bakedWithStationaryMainLight`、`bakedWithHighpLightmap` 等场景全局状态。 - -### 2.3 引擎数据 - -Light Probe 配置和结果位于: - -```text -scene.globals.lightProbeInfo - giScale - giSamples - bounces - reduceRinging - data.probes[] - position - normal - coefficients[] - data.tetrahedrons[] -``` - -`LightProbeGroup` 负责生成局部探针位置,并通过 `LightProbeInfo.syncData()`、`update()` 汇总世界坐标及更新四面体。烘焙完成后应调用 `onProbeBakeFinished()`,通知使用探针的模型刷新。 - -引擎参考代码: - -- `resources/3d/engine/cocos/gi/light-probe/light-probe-group.ts` -- `resources/3d/engine/cocos/gi/light-probe/light-probe.ts` -- `resources/3d/engine/cocos/scene-graph/scene-globals.ts` - -## 3. 总体架构 - -```text -MCP/API - |-- scene-bake-light-probes - `-- scene-bake-lightmap - | - v -scene-process business services - |-- LightProbeBakeService -- 写回 SH、通知引擎、Undo、保存场景 - `-- LightmapBakeService -- 导入纹理、绑定组件、Undo、保存场景 - | - v -shared LightFX baking core - |-- scene exporter - |-- texture resolver - |-- lfx.in/out codec - |-- LightFX process and Socket.IO lifecycle - `-- workspace and cleanup - | - v -static/tools/lightmap-tools/LightFX(.exe) -``` - -公共内核只产生结构化烘焙结果,不直接修改场景或 Asset DB。结果提交和回滚由业务服务负责。 - -建议目录: - -```text -src/core/scene/scene-process/service/baking/lightfx/ - types.ts - buffer.ts - format.ts - exporter.ts - texture-resolver.ts - process.ts - workspace.ts - baker.ts - -src/core/scene/scene-process/service/light-probe.ts -src/core/scene/scene-process/service/lightmap.ts -``` - -公共类型和 API: - -```text -src/core/scene/common/light-probe.ts -src/core/scene/common/lightmap.ts -src/api/scene/light-probe-schema.ts -src/api/scene/light-probe.ts -src/api/scene/lightmap-schema.ts -src/api/scene/lightmap.ts -``` - -## 4. 公共 LightFX 内核 - -### 4.1 烘焙目标 - -内核内部支持目标枚举: - -```ts -type LightFXBakeTarget = 'light-probe' | 'lightmap'; -``` - -内部协议保留两个烘焙目标位,但不公开 `both`,也不让一个业务服务同时提交两类结果。 - -### 4.2 场景导出 - -导出器从当前 scene-process 中的真实引擎对象读取数据,至少覆盖: - -- 场景名称和全局烘焙配置。 -- 非 `Movable` 的有效节点。 -- MeshRenderer/SkinnedMeshRenderer 所需网格数据。 -- Terrain 数据。 -- DirectionalLight、SphereLight、SpotLight。 -- 材质的 diffuse、emissive、metallic、roughness、alpha cutoff 及相关贴图。 -- Light Probe 的世界坐标和法线。 - -过滤规则必须与 Creator 对齐: - -- Light Probe Bake 只导出 `bakeSettings.bakeToLightProbe` 为真的模型。 -- Lightmap Bake 根据 `bakeable`、`castShadow` 和 `receiveShadow` 决定导出和接收行为。 -- inactive 节点、不可用组件和 `Movable` 节点不参与静态烘焙。 -- HDR 与非 HDR 下的光强换算保持 Creator 行为。 - -导出器不能访问 MCP、Undo 或场景保存服务,使其可以用构造的场景对象单独测试。 - -### 4.3 纹理解析 - -材质可能引用普通资源 UUID或带子资源后缀的 UUID。纹理解析器通过主进程 Asset DB RPC: - -- 普通 UUID:查询真实文件路径。 -- library 子资源:定位项目 `library//` 文件。 -- 缺失资源:记录明确的资源和材质信息;必要贴图缺失时失败,可选贴图可降级为空。 - -纹理文件复制到本次烘焙 workspace,文件名必须稳定并避免不同目录同名冲突。 - -### 4.4 二进制协议 - -`format.ts` 和 `buffer.ts` 负责 Creator/LightFX 使用的 `lfx.in`、`lfx.out` 协议,包括: - -- 文件版本和 chunk ID。 -- Settings。 -- Terrain、Mesh、Material、Light、LightProbe 输入。 -- Terrain/Mesh Lightmap 信息和 LightProbe 输出。 -- 数组长度、字符串、整数和浮点数的边界检查。 - -解析输出时必须拒绝: - -- 不支持的版本。 -- 未知或截断的 chunk。 -- 非有限浮点数。 -- 负数或异常大的数组长度。 -- 探针、模型或地形索引越界。 - -### 4.5 Workspace - -每次烘焙使用唯一工作目录,不能复用 Creator 固定的 `temp/light-probe`: - -```text -/temp/lightfx-bake// - tmp/lfx.in - tmp/ - output/lfx.out - output/ -``` - -规则: - -- 成功后默认清理临时输入;Lightmap 输出完成资产提交后再清理。 -- 失败、取消和超时均执行 finally 清理。 -- 临时文件不作为 MCP 输出;成功、失败、取消和超时均由服务清理自身 workspace。 -- 不删除 operation-id 目录以外的任何文件。 - -### 4.6 LightFX 进程生命周期 - -进程层负责: - -1. 从 `GlobalPaths.staticDir/tools/lightmap-tools` 定位平台可执行文件。 -2. 创建本地 Socket.IO 服务并监听随机端口。 -3. 启动 LightFX,将本地 URL 作为参数传入。 -4. 等待 Login,发送 Start,接收 Log、Progress、Finished。 -5. Finished 后读取完整 `lfx.out`,再发送 Stop。 -6. 关闭 Socket.IO 服务并终止子进程。 - -必须处理: - -- 工具不存在或没有执行权限。 -- 端口创建失败。 -- Login 超时。 -- LightFX 非零退出或异常退出。 -- 输出文件缺失、尚未写完或解析失败。 -- 用户取消和总流程超时。 -- 服务或进程只能完成一次清理,避免重复 resolve/reject。 - -公共内核同一时间默认只允许一个 LightFX 烘焙任务,避免多个进程争用 CPU、端口或项目资源。 - -### 4.7 进度事件 - -公共进度阶段: - -```ts -type LightFXBakeStage = - | 'validating' - | 'exporting-scene' - | 'resolving-textures' - | 'starting-baker' - | 'baking' - | 'reading-result' - | 'applying-result' - | 'saving-scene' - | 'completed'; -``` - -业务服务广播内部进度事件;MCP 调用等待最终结果,不依赖 Inspector 对通知的展示能力。 - -## 5. Light Probe Bake - -### 5.1 MCP 接口 - -工具名: - -```text -scene-bake-light-probes -``` - -调用参数: - -```ts -interface ILightProbeBakeOptions { - giScale?: number; - giSamples?: number; - bounces?: number; - saveScene?: boolean; - timeoutMs?: number; -} -``` - -- `saveScene` 默认 `true`。 -- `timeoutMs` 覆盖完整流程,默认建议 600 秒,并设置合理最大值。 -- `giScale`、`giSamples`、`bounces` 真正参与 LightFX 计算,允许 MCP 对本次烘焙进行可选覆盖。 -- 未传入覆盖值时,从 `scene.globals.lightProbeInfo` 读取当前值。 -- 覆盖值默认只作用于本次烘焙,不修改 `LightProbeInfo` 的持久化配置;如需永久修改,应通过场景属性编辑接口完成。 -- 参数校验与引擎约束一致:`giScale` 为 `[0, 100]` 的有限数,`giSamples` 为 `[64, 65535]` 的整数,`bounces` 为 `[1, 4]` 的整数。 -- 烘焙范围为当前打开场景中的全部有效 LightProbeGroup,而不是某个 `nodePath`。 - -以下 `LightProbeInfo` 参数不进入 Bake 接口: - -- `reduceRinging`:运行时对 SH 系数的振铃抑制参数,不参与 LightFX 烘焙计算。 -- `showProbe`、`showWireframe`、`showConvex`:编辑器可视化开关。 -- `lightProbeSphereVolume`:编辑器中的探针显示尺寸。 - -这些参数仍保留在场景中,烘焙不会覆盖它们。 - -MCP JSON 示例: +工具名:`scene-bake-light-probes` ```json { @@ -315,123 +37,53 @@ MCP JSON 示例: } ``` -返回值: - -```ts -interface ILightProbeBakeResult { - sceneUrl: string; - probeCount: number; - giScale: number; - giSamples: number; - bounces: number; - durationMs: number; -} -``` - -### 5.2 前置校验 - -- 当前打开的是具有 Asset URL 的场景,不支持 prefab。 -- `lightProbeInfo.data` 存在。 -- 至少存在 4 个有效探针,并已建立四面体数据。 -- 所有 position、normal 和配置值均为有限数。 -- 当前没有其他 LightFX 烘焙任务。 -- LightFX 工具存在并可启动。 - -若用户只添加了 LightProbeGroup 但没有生成探针,应返回明确提示,而不是输出空结果。 - -### 5.3 结果校验与提交 - -提交前校验: - -- 输出探针数量与请求输入一致。 -- 探针顺序与输入一致;位置应在允许误差内匹配。 -- 每个探针的 SH coefficient 数量符合引擎 `SH.getBasisCount()`。 -- 所有系数均为有限数。 - -提交时序: - -```text -保存旧 coefficients - -> begin Undo recording - -> 一次性写入全部 coefficients/normal - -> lightProbeInfo.onProbeBakeFinished() - -> Engine.repaintInEditMode() - -> 按需保存场景 - -> end Undo recording -``` - -若提交或保存失败: - -- cancel Undo recording。 -- 恢复旧 coefficients 和 normal。 -- 再次通知引擎并重绘。 -- 返回失败,不留下部分探针的新数据。 +参数: -Light Probe 结果直接序列化在 `.scene` 中,不创建新的 Asset DB 资源。 +| 参数 | 范围 | 默认行为 | +| --- | --- | --- | +| `giScale` | 0–100 | 使用场景 `lightProbeInfo.giScale` | +| `giSamples` | 64–65535,整数 | 使用场景 `lightProbeInfo.giSamples` | +| `bounces` | 1–4,整数 | 使用场景 `lightProbeInfo.bounces` | +| `saveScene` | boolean | `true` | +| `timeoutMs` | 1000–3600000 ms | 600000 ms | -### 5.4 清除接口 +这些覆盖参数只影响本次烘焙,不会修改 LightProbeInfo 的持久化配置。`reduceRinging`、`showWireframe`、`showConvex` 和探针显示尺寸不参与 LightFX 计算,因此不属于该接口参数。 -清除烘焙结果使用独立工具: +成功返回示例: -```text -scene-clear-light-probes +```json +{ + "result": { + "code": 200, + "data": { + "sceneUrl": "db://assets/LightProbe.scene", + "probeCount": 125, + "giScale": 8, + "giSamples": 4096, + "bounces": 1, + "durationMs": 1630 + } + } +} ``` -该工具调用 `lightProbeInfo.onProbeBakeCleared()`,进入 Undo,并按需保存场景。 - -## 6. Lightmap Bake - -### 6.1 MCP 接口 +### 清理 Light Probe -工具名: +工具名:`scene-clear-light-probes` -```text -scene-bake-lightmap -``` - -调用参数: - -```ts -interface ILightmapBakeOptions { - msaa?: 1 | 2 | 4 | 8; - resolution?: number; - filter?: boolean; - highp?: boolean; - giScale?: number; - giSamples?: number; - giPathLength?: number; - aoLevel?: number; - aoStrength?: number; - aoRadius?: number; - aoColor?: [number, number, number, number?]; - threads?: number; - saveScene?: boolean; - timeoutMs?: number; +```json +{ + "options": { + "saveScene": true + } } ``` -- `msaa`、`resolution`、`filter`、`highp`、GI、AO 和 `threads` 都会影响 LightFX 计算,允许 MCP 对本次烘焙进行可选覆盖。 -- 未传入的参数使用与 Creator 面板初始值一致的 CLI 默认值。 -- MCP 覆盖值默认只作用于本次烘焙,不写回项目 Lightmap 配置。永久修改配置应使用独立配置接口。 -- 不开放 `outputDir`,统一输出到场景对应目录,不允许传入任意文件系统路径。 +该操作清除当前场景全部探针的烘焙结果,通知引擎刷新,并作为一次 Undo 操作记录。成功结果中的 `probeCount` 表示处理的探针数量。 -面板参数映射: +### 烘焙 Lightmap -| Creator 面板 | MCP 参数 | LightFX 字段 | -| --- | --- | --- | -| 多重采样抗锯齿 | `msaa` | `MSAA` | -| 烘焙分辨率 | `resolution` | `Size` | -| 应用线性过滤 | `filter` | `Filter` | -| 高精度烘焙 | `highp` | `Highp` | -| 全局光照倍数 | `giScale` | `GIScale` | -| 全局光照采样点 | `giSamples` | `GISamples` | -| 光线追踪次数 | `giPathLength` | `GIPathLength` | -| 环境光遮蔽等级 | `aoLevel` | `AOLevel` | -| 环境光遮蔽强度 | `aoStrength` | `AOStrength` | -| 环境光遮蔽半径 | `aoRadius` | `AORadius` | -| 环境光遮蔽颜色 | `aoColor` | `AOColor` | - -MCP JSON 示例: +工具名:`scene-bake-lightmap` ```json { @@ -447,207 +99,168 @@ MCP JSON 示例: "aoStrength": 0.5, "aoRadius": 1, "aoColor": [136, 136, 136, 255], + "threads": 1, "saveScene": true, "timeoutMs": 600000 } } ``` -返回值包含: +参数: -```ts -interface ILightmapBakeResult { - sceneUrl: string; - textureUrls: string[]; - meshCount: number; - terrainCount: number; - durationMs: number; +| 参数 | 范围 | CLI 默认值 | +| --- | --- | --- | +| `msaa` | 1、2、4、8 | 4 | +| `resolution` | 128–8192,整数 | 1024 | +| `filter` | boolean | `true` | +| `highp` | boolean | `false` | +| `giScale` | 0–100 | 1 | +| `giSamples` | 1–65535,整数 | 25 | +| `giPathLength` | 1–64,整数 | 4 | +| `aoLevel` | 0–2,整数 | 0 | +| `aoStrength` | ≥ 0 | 0.5 | +| `aoRadius` | ≥ 0 | 1 | +| `aoColor` | 3 个 RGB 值及可选 Alpha,单项 0–255 | `[136, 136, 136]` | +| `threads` | 1–256,整数 | 1 | +| `saveScene` | boolean | `true` | +| `timeoutMs` | 1000–3600000 ms | 600000 ms | + +未传入的参数使用 CLI 默认值。参数只影响本次烘焙,不写回 Creator 的 Lightmap 面板配置。 + +成功返回示例: + +```json +{ + "result": { + "code": 200, + "data": { + "sceneUrl": "db://assets/LightProbe.scene", + "textureUrls": [ + "db://assets/LightProbe/lightmap/LFX_Mesh_0000.png", + "db://assets/LightProbe/lightmap/LFX_Terrain_0000.png" + ], + "meshCount": 7, + "terrainCount": 1, + "durationMs": 4668 + } + } } ``` -### 6.2 资产输出 +### 清理 Lightmap -建议稳定输出到: +工具名:`scene-clear-lightmap` -```text -db://assets//lightmap/ -``` +只解除场景绑定并保留贴图: -不能直接让 LightFX 写入最终资产目录。流程应为: +```json +{ + "options": { + "saveScene": true, + "deleteAssets": false + } +} +``` -1. LightFX 写入唯一临时 workspace。 -2. 完整校验 `lfx.out` 和所有引用 PNG。 -3. 将 PNG 和 meta 暂存到最终目录旁的临时名称。 -4. 原子替换最终文件,并保留事务备份。 -5. Asset DB refresh/import。 -6. 等待 Texture2D 子资源可查询和加载。 -7. 绑定模型与地形。 -8. 保存场景后提交文件事务。 +解除绑定并删除当前场景生成的 Lightmap 目录: -重烘焙必须尽量复用已有资源 UUID,避免场景引用和版本管理中持续产生新资产。 +```json +{ + "options": { + "saveScene": true, + "deleteAssets": true + } +} +``` -### 6.3 图片导入 +`saveScene` 默认为 `true`,`deleteAssets` 默认为 `false`。成功结果中的 `clearedCount` 是解除绑定的 Mesh 和 Terrain block 总数。 -图片 meta 至少保证: +### 取消烘焙 -- 作为 Texture2D 导入。 -- `fixAlphaTransparencyArtifacts=false`,与 Creator 行为一致。 -- 高精度、颜色空间、filter、wrap 等选项由 Lightmap 输出规范明确设置,不能依赖导入器偶然默认值。 +工具名:`scene-cancel-lightfx-bake` -Asset DB refresh 后必须轮询目标 Texture2D 是否真正可加载,不能只等待文件事件。 +该工具没有输入参数: -### 6.4 结果绑定 +```json +{} +``` -根据 `lfx.out` 中稳定的导出索引绑定: +成功返回示例: -- MeshRenderer:纹理、offset.x/y、scale.x/y。 -- Terrain block:纹理、block id、offset 和 scale。 -- Stationary 主灯及高精度 Lightmap 对应的场景全局标志。 +```json +{ + "result": { + "code": 200, + "data": { + "cancelled": true, + "target": "lightmap" + } + } +} +``` -导出阶段必须建立 `export index -> engine object/component UUID` 映射,禁止在结果阶段重新按场景遍历顺序猜测对象。 +没有任务运行时,返回 `cancelled: false` 和 `target: null`。 -### 6.5 Lightmap 事务 +## Lightmap 资产规则 -Lightmap 同时修改文件资产和场景,事务边界为: +Lightmap 统一输出到: ```text -生成并校验临时结果 - -> 备份/替换最终 PNG 和 meta - -> Asset DB 导入并加载 Texture2D - -> begin Undo recording - -> 绑定全部 Mesh/Terrain 并更新 globals - -> 保存场景 - -> end Undo recording - -> 删除文件备份 +db://assets//lightmap/ ``` -失败时按相反顺序回滚: +典型文件包括: -- 恢复组件原 Lightmap 引用和 globals。 -- cancel Undo recording。 -- 恢复旧 PNG/meta 或删除本次新增文件。 -- 刷新 Asset DB,使内存资源状态与磁盘一致。 +```text +LFX_Mesh_0000.png +LFX_Terrain_0000.png +``` -回滚实现应复用 Reflection Probe Bake 已验证的文件替换事务思想,但抽成通用文件事务后再由 Lightmap 使用。 +- Mesh 与 Terrain 使用独立的类型和索引映射,避免两者均从索引 0 开始时串绑贴图。 +- 重复烘焙会保留同名贴图的 `.meta`,从而复用 Asset UUID。 +- 导入后将 `fixAlphaTransparencyArtifacts` 设置为 `false`,再加载 Texture2D 子资源并绑定。 +- 资产导入、组件绑定或场景保存失败时,恢复原贴图目录、组件绑定和场景全局标记。 +- 成功、失败、取消和超时都会清理本次 LightFX workspace。 -### 6.6 清除接口 +## Creator 互操作说明 -清理工具: +CLI 烘焙并保存后,Creator 重新打开场景可以正常加载和显示 Light Probe 与 Lightmap 结果。 -```text -scene-clear-lightmap -``` +Creator Lightmap 面板的“清除”操作依赖该面板自己保存的 `latestLightmapResultDir`。CLI 不写入 Creator 的私有面板状态,因此 Creator 面板可能无法清除 CLI 生成的 Lightmap。请使用 `scene-clear-lightmap` 清理 CLI 烘焙结果。CLI 不伪造 Creator Profile 状态,以避免耦合面板内部实现或误删资源。 -`scene-clear-lightmap` 解除组件绑定并更新 globals。是否删除磁盘纹理由 `deleteAssets` 显式控制,默认只解除绑定,避免破坏被其他场景引用的资源。 +## 运行时兼容性 -## 7. 进程与运行环境边界 +随 Creator 提供的 LightFX 可执行程序使用 Socket.IO 2.x 协议,而 CLI 现有服务使用 Socket.IO 4.x。项目通过 npm alias `socket.io-v2` 提供仅供 LightFX 本地进程桥接使用的 2.3.0 服务: -LightFX 烘焙不同于 Reflection Probe 捕获: +- 只监听本机随机端口。 +- 不替换 MCP 或其他现有 Socket.IO 4.x 服务。 +- LightFX 升级并支持 Socket.IO 4.x 后可以移除该兼容依赖。 -- 不需要 WebGL 六面渲染。 -- 不需要 `/scene-editor/` 保持打开或可见。 -- 不通过 Socket.IO 回传大块 RGBA 数据。 -- 不依赖 MCP Server 的 `maxHttpBufferSize`。 +LightFX 当前可能输出 Creator 历史协议版本。解析器只接受已知兼容版本,并拒绝未知版本、截断数据、非法长度及非有限浮点数。 -烘焙运行在 Node scene-process,文件、Asset DB、配置和工具路径等 Node 能力通过现有 RPC 访问主进程。LightFX 自己使用的本地 Socket.IO 只用于 CLI 与外部烘焙进程通信,不是浏览器场景渲染器通道。 +## 错误与事务 -因此未来 VSCode/Pink 编辑器只要通过 CLI 打开了可在 scene-process 中完整加载的场景,即可调用这两个 MCP 工具。 +常见错误包括: -## 8. 并发、取消和超时 +- 当前没有打开已保存场景。 +- 探针不足、未生成或没有可烘焙 Mesh/Terrain。 +- 场景依赖资产缺失。 +- LightFX 缺失、启动失败、连接失败、超时或异常退出。 +- 输出协议不兼容或结果损坏。 +- Asset DB 导入、Texture2D 加载或场景保存失败。 +- 已有另一个 LightFX 任务运行。 -- 全局同一时间只允许一个 LightFX 任务。 -- 重复调用立即返回“已有烘焙任务运行中”,不进入等待队列。 -- 业务接口内部使用 operation id,所有事件、workspace 和结果均绑定该 id。 -- 超时覆盖导出、工具启动、烘焙、结果解析、资源导入和场景保存。 -- 取消应同时终止 LightFX、关闭 Socket.IO、停止结果提交并清理 workspace。 -- 一旦进入结果提交阶段,取消按失败处理并执行事务回滚。 +Bake 和 Clear 都记录为单次 Undo 操作。场景结果提交失败时恢复原组件数据和全局标记;Lightmap 资产提交失败时还会恢复原 PNG 与 `.meta`。 -公开的 `scene-cancel-lightfx-bake` 可取消当前任务,并返回是否取消成功及任务类型。 +## 验证范围 -## 9. 错误模型 +当前实现已经验证: -错误信息至少区分: +- Light Probe Bake/Clear,包含 SH 数据保存和重新加载。 +- Mesh Lightmap Bake/Clear。 +- Terrain Lightmap Bake/Clear。 +- Mesh 与 Terrain 混合场景的独立贴图绑定。 +- 重复烘焙的 `.meta` 与 UUID 复用。 +- TypeScript 编译、ESLint、API、协议和资产事务测试。 -- 场景未打开或不是场景资产。 -- 没有探针、探针不足或未生成四面体。 -- 没有可烘焙模型/地形。 -- 场景依赖资产缺失。 -- LightFX 工具缺失或不支持当前平台。 -- LightFX 启动、连接、执行或退出失败。 -- 输入/输出协议不兼容或结果损坏。 -- 探针、Mesh、Terrain 结果数量不匹配。 -- Asset DB 导入或 Texture2D 加载超时。 -- 场景保存失败及回滚失败。 - -对用户返回简洁原因;详细子进程 stdout/stderr、阶段和 operation id 写入 CLI 日志。日志不能输出完整二进制数据或大块纹理内容。 - -## 10. 测试方案 - -### 10.1 公共内核单元测试 - -- `lfx.in` 固定 fixture 编码结果与 Creator 兼容。 -- `lfx.out` fixture 能正确解析 Light Probe、Mesh 和 Terrain 结果。 -- 截断、未知版本、非法长度、NaN/Infinity 被拒绝。 -- 场景过滤和导出索引稳定。 -- 纹理 UUID、子资源和缺失资源解析。 -- LightFX 正常结束、异常退出、超时、取消和重复清理。 -- workspace 只清理自身目录。 - -### 10.2 Light Probe 测试 - -- API schema 和 MCP 工具注册。 -- 无场景、无探针、少于 4 个探针。 -- 未传覆盖参数时从场景读取 `giScale/giSamples/bounces`。 -- 覆盖参数只影响本次 LightFX 输入,不意外改写场景配置。 -- `reduceRinging` 和可视化参数不进入烘焙参数。 -- 探针数量或位置不匹配时不写回。 -- 成功时一次性写回 SH、通知引擎并保存。 -- 写回或保存失败时恢复全部旧系数。 -- 重复烘焙和并发调用。 - -### 10.3 Lightmap 测试 - -- Mesh/Terrain 导出和结果索引映射。 -- Lightmap 项目配置默认值、MCP 部分覆盖及参数校验。 -- MCP 覆盖参数只影响本次任务,不意外写回项目配置。 -- PNG/meta 创建、覆盖及 UUID 复用。 -- Asset DB 导入后等待 Texture2D。 -- 绑定 offset/scale 和 globals。 -- 文件替换后导入失败、绑定失败、保存失败的完整回滚。 -- 重复烘焙不残留 backup、staging 或临时目录。 - -### 10.4 端到端验证场景 - -至少准备: - -1. 基础 Mesh、DirectionalLight 和单个 LightProbeGroup。 -2. 多个 LightProbeGroup,验证世界坐标汇总与顺序。 -3. SphereLight、SpotLight、发光材质和纹理材质。 -4. HDR 与非 HDR 场景。 -5. Mesh 与 Terrain 混合的 Lightmap 场景。 -6. 重复烘焙、取消、超时和缺失贴图场景。 - -端到端验证需在重新打开场景后确认: - -- Light Probe SH 数据仍存在,动态模型间接光正确。 -- Lightmap 纹理引用有效,模型和 Terrain 显示正确。 -- 场景和资产目录没有 staging、backup 或失效 meta 残留。 - -## 11. 已实现能力与验收标准 - -- 公共 LightFX 场景导出、二进制协议、进程管理、超时、取消和 workspace 清理。 -- `scene-bake-light-probes` 与 `scene-clear-light-probes`,包括 SH 回填、Undo、失败恢复和场景保存。 -- `scene-bake-lightmap` 与 `scene-clear-lightmap`,包括 PNG 导入、meta/UUID 复用、Mesh/Terrain 独立绑定、Undo、失败恢复和可选资源删除。 -- `scene-cancel-lightfx-bake`,用于取消当前 LightFX 任务。 - -提交验收要求:Light Probe、Mesh Lightmap 和 Terrain Lightmap 的 Bake/Clear 均通过真实场景验证;重复烘焙不改变已有贴图 UUID;重新打开场景后数据与资源引用仍有效;编译、协议测试、API 测试及资产事务测试通过。 - -## 12. 实现约束与评审重点 - -- 公共内核不得依赖 Creator 的 `Editor.Message`、Panel 或 Metrics。 -- MCP API 不直接操作 LightFX、文件或引擎对象,只调用场景服务。 -- 不把 Lightmap 资产导入逻辑放入公共 exporter。 -- 不在循环内反复创建 Undo snapshot;一次烘焙只形成一个业务操作。 -- 所有外部进程、Socket.IO 服务和临时目录必须有确定的 finally 清理路径。 -- 所有最终文件替换必须可回滚,不能先删除旧资产再尝试导入新资产。 -- 公共接口变更必须同时验证 Light Probe 与包含 Mesh/Terrain 的 Lightmap 场景。 +新增材质类型、灯光类型、LightFX 版本或目标平台时,应补充对应真实场景回归。 From 3373ada36c2e7ec5c2c549cc841605cffb5ecb3b Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Fri, 4 Sep 2026 17:49:04 +0800 Subject: [PATCH 04/64] fix(scene): support LightFX baking in active renderer --- docs/dev/scene/lightfx-bake.md | 14 +- .../__snapshots__/dts-snapshot.test.ts.snap | 63 ++ src/core/scene/common/lightfx-host.ts | 93 +++ src/core/scene/main-process/index.ts | 2 + .../scene/main-process/lightfx-bake-host.ts | 606 ++++++++++++++++++ .../main-process/lightfx-bake-renderer.ts | 118 ++++ .../lightfx/asset-transaction.ts | 28 +- src/core/scene/main-process/lightfx/output.ts | 121 ++++ .../scene/main-process/lightfx/process.ts | 141 ++++ .../main-process/proxy/lightfx-bake-proxy.ts | 29 +- .../main-process/scene-host-local-executor.ts | 7 + .../scene/scene-process/engine-bootstrap.ts | 72 +++ .../service/baking/lightfx/baker.ts | 79 ++- .../service/baking/lightfx/buffer.ts | 14 +- .../service/baking/lightfx/exporter.ts | 31 +- .../service/baking/lightfx/format.ts | 15 +- .../service/baking/lightfx/host.ts | 25 + .../service/baking/lightfx/process.ts | 73 --- .../service/baking/lightfx/types.ts | 10 +- .../scene-process/service/light-probe-bake.ts | 5 +- .../scene-process/service/lightmap-bake.ts | 81 +-- .../test/lightfx-asset-transaction.test.ts | 17 +- src/core/scene/test/lightfx-bake-host.test.ts | 302 +++++++++ .../scene/test/lightfx-bake-renderer.test.ts | 95 +++ src/core/scene/test/lightfx-format.test.ts | 3 +- src/server/socket.ts | 20 + workflow/build-scene-bundle.js | 10 +- 27 files changed, 1867 insertions(+), 207 deletions(-) create mode 100644 src/core/scene/common/lightfx-host.ts create mode 100644 src/core/scene/main-process/lightfx-bake-host.ts create mode 100644 src/core/scene/main-process/lightfx-bake-renderer.ts rename src/core/scene/{scene-process/service/baking => main-process}/lightfx/asset-transaction.ts (53%) create mode 100644 src/core/scene/main-process/lightfx/output.ts create mode 100644 src/core/scene/main-process/lightfx/process.ts create mode 100644 src/core/scene/scene-process/service/baking/lightfx/host.ts delete mode 100644 src/core/scene/scene-process/service/baking/lightfx/process.ts create mode 100644 src/core/scene/test/lightfx-bake-host.test.ts create mode 100644 src/core/scene/test/lightfx-bake-renderer.test.ts diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index d30218f5a..8df672847 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -9,15 +9,17 @@ Cocos CLI 通过 Creator 随附的 LightFX 工具提供离线光照烘焙能力 - 清理:解除 Light Probe 或 Lightmap 的烘焙结果,可选择保存场景及删除 Lightmap 资产。 - 取消:终止当前正在运行的 LightFX 任务。 -MCP API 只负责参数校验和结果封装。场景数据读取、LightFX 调用、结果绑定、Undo、保存和回滚均在 scene-process 服务中完成。Light Probe 与 Lightmap 共享场景导出、二进制协议、进程管理和临时目录管理。 +MCP API 只负责参数校验和结果封装。场景运行时负责导出场景数据、应用烘焙结果、Undo、重绘和保存;Node Host 负责启动 LightFX、临时文件、Asset DB 导入和资产事务。Light Probe 与 Lightmap 共享场景导出、二进制协议、进程管理和临时目录管理。 + +在 Pink 等集成场景编辑器中,CLI 会把请求路由到当前可见且已加载场景的 WebGL Scene Webview,使烘焙结果立即显示在正在编辑的场景中。没有连接 Scene Webview 时,CLI 才回退到 scene-process worker。 ## 使用前提 -1. 使用 CLI 打开一个已保存的 `.scene` 资产;不支持未保存场景和 prefab。 +1. 当前场景必须是已保存的 `.scene` 资产;不支持未保存场景和 prefab。 2. Light Probe 烘焙前,场景中需要至少 4 个已生成的有效探针。 3. Lightmap 烘焙前,需要在 MeshRenderer、SkinnedMeshRenderer 或 Terrain 上配置有效的烘焙设置。 4. 同一时间只允许运行一个 LightFX 烘焙任务。 -5. LightFX 在 Node scene-process 中执行,不要求打开浏览器 `/scene-editor/`,也不依赖 WebGL 场景渲染器。 +5. 在 Pink 中调用时,目标场景必须已在当前可见的场景视图中加载完成;不需要额外调用 `scene-open`。同时存在多个可见场景视图时,应先激活目标场景标签并关闭重复视图。 ## MCP 工具 @@ -201,6 +203,8 @@ MCP API 只负责参数校验和结果封装。场景数据读取、LightFX 调 没有任务运行时,返回 `cancelled: false` 和 `target: null`。 +取消成功后,取消工具本身返回 `code: 200`;原烘焙请求结束并返回 `code: 500`、`reason: "LightFX bake was cancelled."`。这是被取消任务的预期终态。 + ## Lightmap 资产规则 Lightmap 统一输出到: @@ -224,7 +228,7 @@ LFX_Terrain_0000.png ## Creator 互操作说明 -CLI 烘焙并保存后,Creator 重新打开场景可以正常加载和显示 Light Probe 与 Lightmap 结果。 +CLI 烘焙并保存后,Creator 重新打开场景可以正常加载和显示 Light Probe 与 Lightmap 结果。在 Pink 中通过当前可见的 Scene Webview 烘焙时,结果会直接应用并重绘,无需重启编辑器。 Creator Lightmap 面板的“清除”操作依赖该面板自己保存的 `latestLightmapResultDir`。CLI 不写入 Creator 的私有面板状态,因此 Creator 面板可能无法清除 CLI 生成的 Lightmap。请使用 `scene-clear-lightmap` 清理 CLI 烘焙结果。CLI 不伪造 Creator Profile 状态,以避免耦合面板内部实现或误删资源。 @@ -249,6 +253,7 @@ LightFX 当前可能输出 Creator 历史协议版本。解析器只接受已知 - 输出协议不兼容或结果损坏。 - Asset DB 导入、Texture2D 加载或场景保存失败。 - 已有另一个 LightFX 任务运行。 +- 当前可见场景尚未加载完成,或同时存在多个可见的场景渲染器。 Bake 和 Clear 都记录为单次 Undo 操作。场景结果提交失败时恢复原组件数据和全局标记;Lightmap 资产提交失败时还会恢复原 PNG 与 `.meta`。 @@ -261,6 +266,7 @@ Bake 和 Clear 都记录为单次 Undo 操作。场景结果提交失败时恢 - Terrain Lightmap Bake/Clear。 - Mesh 与 Terrain 混合场景的独立贴图绑定。 - 重复烘焙的 `.meta` 与 UUID 复用。 +- Pink 当前可见场景中的即时结果应用、清理和取消。 - TypeScript 编译、ESLint、API、协议和资产事务测试。 新增材质类型、灯光类型、LightFX 版本或目标平台时,应补充对应真实场景回归。 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 d91da7363..ca0e53137 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 @@ -6399,6 +6399,67 @@ export declare interface IInsertLODOptions { export declare interface IIsPrefabInstanceParams { nodePath: string; } +export declare interface ILightFXCancelResult { + cancelled: boolean; + target: 'light-probe' | 'lightmap' | null; +} +export declare interface ILightmapBakeOptions { + msaa?: 1 | 2 | 4 | 8; + resolution?: number; + filter?: boolean; + highp?: boolean; + giScale?: number; + giSamples?: number; + giPathLength?: number; + aoLevel?: number; + aoStrength?: number; + aoRadius?: number; + aoColor?: [number, number, number, number?]; + threads?: number; + saveScene?: boolean; + timeoutMs?: number; +} +export declare interface ILightmapBakeResult { + sceneUrl: string; + textureUrls: string[]; + meshCount: number; + terrainCount: number; + durationMs: number; +} +export declare interface ILightmapBakeService extends IServiceEvents { + bake(options: ILightmapBakeOptions): Promise; + clearBake(options?: { + saveScene?: boolean; + deleteAssets?: boolean; + }): Promise<{ + clearedCount: number; + }>; + cancel(): Promise; +} +export declare interface ILightProbeBakeOptions { + giScale?: number; + giSamples?: number; + bounces?: number; + saveScene?: boolean; + timeoutMs?: number; +} +export declare interface ILightProbeBakeResult { + sceneUrl: string; + probeCount: number; + giScale: number; + giSamples: number; + bounces: number; + durationMs: number; +} +export declare interface ILightProbeBakeService extends IServiceEvents { + bake(options: ILightProbeBakeOptions): Promise; + clearBake(options?: { + saveScene?: boolean; + }): Promise<{ + probeCount: number; + }>; + cancel(): Promise; +} export declare interface ILODGroupBoundsResult { localBoundaryCenter: IVec3; objectSize: number; @@ -6866,6 +6927,8 @@ export declare interface IServiceManager { Preview: IPreviewService; UI: IUIService; ReferenceImage: IReferenceImageService; + LightProbeBake: ILightProbeBakeService; + LightmapBake: ILightmapBakeService; } export declare interface ISetParentParams { paths: string[]; diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts new file mode 100644 index 000000000..919c2411b --- /dev/null +++ b/src/core/scene/common/lightfx-host.ts @@ -0,0 +1,93 @@ +/** A bake target supported by the native LightFX process. */ +export type LightFXBakeTarget = 'light-probe' | 'lightmap'; + +/** JSON-safe reference to a texture needed by a LightFX input file. */ +export interface ILightFXTextureSource { + uuid: string; + nativeExtension: string; + fileName: string; +} + +export interface IResolveLightFXTextureSourceOptions { + uuid: string; + nativeExtension: string; +} + +export interface IResolvedLightFXTextureSource { + fileName: string; +} + +export interface IBeginLightFXBakeOptions { + target: LightFXBakeTarget; + sceneName: string; + textureSources: ILightFXTextureSource[]; + timeoutMs: number; +} + +export interface IBeginLightFXBakeResult { + operationId: string; +} + +export interface IAppendLightFXInputOptions { + operationId: string; + chunkBase64: string; +} + +export interface IRunLightFXBakeOptions { + operationId: string; +} + +export interface ILightFXMeshResult { + id: number; + index: number; + offset: number[]; + scale: number[]; +} + +export interface ILightFXTerrainResult extends ILightFXMeshResult { + blockId: number; +} + +export interface ILightFXProbeResult { + position: number[]; + normal: number[]; + coefficients: number[]; +} + +/** Decoded LightFX output. It intentionally contains JSON-safe values only. */ +export interface ILightFXResult { + version: number; + meshes: ILightFXMeshResult[]; + terrains: ILightFXTerrainResult[]; + probes: ILightFXProbeResult[]; +} + +export interface IRunLightFXBakeResult { + result: ILightFXResult; + textureUrls: string[]; +} + +export interface ILightFXOperationOptions { + operationId: string; +} + +export interface IRemoveLightmapAssetsOptions { + sceneName: string; +} + +/** + * Node-hosted half of LightFX baking. + * + * The Scene runtime can be a child process or a browser Webview. Consequently every argument and + * return value in this contract must remain JSON serializable and must not expose host file paths. + */ +export interface ILightFXBakeHostService { + resolveTextureSource(options: IResolveLightFXTextureSourceOptions): Promise; + begin(options: IBeginLightFXBakeOptions): Promise; + appendInput(options: IAppendLightFXInputOptions): Promise; + run(options: IRunLightFXBakeOptions): Promise; + commit(options: ILightFXOperationOptions): Promise; + rollback(options: ILightFXOperationOptions): Promise; + cancel(): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }>; + removeLightmapAssets(options: IRemoveLightmapAssetsOptions): Promise; +} diff --git a/src/core/scene/main-process/index.ts b/src/core/scene/main-process/index.ts index 65f5d4512..561a035cb 100644 --- a/src/core/scene/main-process/index.ts +++ b/src/core/scene/main-process/index.ts @@ -15,6 +15,7 @@ import { sceneConfigInstance } from '../scene-configs'; import i18n from '../../base/i18n'; import { referenceImageFiles } from './reference-image-files'; import { referenceImageStore } from './reference-image-store'; +import { lightFXBakeHost } from './lightfx-bake-host'; export interface IMainModule { 'assetManager': typeof assetManager; @@ -23,6 +24,7 @@ export interface IMainModule { 'i18n': typeof i18n; 'referenceImageFiles': typeof referenceImageFiles; 'referenceImageStore': typeof referenceImageStore; + 'lightFXBakeHost': typeof lightFXBakeHost; } export const Scene = { diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts new file mode 100644 index 000000000..aa4de7606 --- /dev/null +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -0,0 +1,606 @@ +import { randomUUID } from 'crypto'; +import { + appendFile, + copy, + ensureDir, + outputFile, + pathExists, + readFile, + readdir, + remove, +} from 'fs-extra'; +import { basename, dirname, join } from 'path'; +import Utils from '../../base/utils'; +import type { + IAppendLightFXInputOptions, + IBeginLightFXBakeOptions, + IBeginLightFXBakeResult, + ILightFXBakeHostService, + ILightFXOperationOptions, + ILightFXTextureSource, + IRemoveLightmapAssetsOptions, + IResolvedLightFXTextureSource, + IResolveLightFXTextureSourceOptions, + IRunLightFXBakeOptions, + IRunLightFXBakeResult, + LightFXBakeTarget, +} from '../common/lightfx-host'; +import { assetManager } from '../../assets'; +import { LightmapAssetTransaction } from './lightfx/asset-transaction'; +import { decodeLightFXOutput } from './lightfx/output'; +import { LightFXProcess } from './lightfx/process'; + +type OperationState = 'accepting-input' | 'running' | 'awaiting-commit'; +type OperationTerminalState = 'committed' | 'rolled-back' | 'cancelled' | 'expired'; + +interface LightFXHostOperation { + id: string; + target: LightFXBakeTarget; + sceneName: string; + timeoutMs: number; + workspace: string; + inputPath: string; + outputDir: string; + targetDir: string; + targetUrl: string; + refreshUrl: string; + inputBytes: number; + inputWritePromise: Promise; + state: OperationState; + controller: AbortController; + runner: LightFXProcess; + assets: LightmapAssetTransaction | null; + cleanupPromise: Promise | null; + expiryTimer: NodeJS.Timeout | null; + terminalState: OperationTerminalState | null; +} + +interface ResolvedTextureSource extends IResolvedLightFXTextureSource { + sourcePath: string; +} + +const MAX_REMEMBERED_OPERATIONS = 32; +const MAX_INPUT_CHUNK_BASE64_LENGTH = 1024 * 1024; +const MAX_INPUT_BYTES = 1024 * 1024 * 1024; +const MAX_TEXTURE_SOURCES = 10_000; + +/** + * Executes every Node-only part of a LightFX bake on behalf of either a Scene worker or a browser + * Scene Webview. Only one operation can exist at a time, including the apply/save transaction gap. + */ +export class LightFXBakeHost implements ILightFXBakeHostService { + private operation: LightFXHostOperation | null = null; + private readonly completedOperations = new Map(); + + public async resolveTextureSource( + options: IResolveLightFXTextureSourceOptions, + ): Promise { + const resolved = await this.resolveHostTextureSource(options); + return resolved ? { fileName: resolved.fileName } : null; + } + + public async begin(options: IBeginLightFXBakeOptions): Promise { + if (this.operation) { + throw new Error(`A ${this.operation.target} LightFX bake is already in progress.`); + } + this.validateBeginOptions(options); + + const assetRoot = this.queryAssetRoot(); + const projectRoot = dirname(assetRoot); + const operationId = randomUUID(); + const workspace = join( + projectRoot, + 'temp', + 'lightfx-bake', + `${options.target}-${Date.now()}-${process.pid}-${operationId.slice(0, 8)}`, + ); + const tmpDir = join(workspace, 'tmp'); + const outputDir = join(workspace, 'output'); + const targetDir = join(assetRoot, options.sceneName, 'lightmap'); + const targetUrl = `db://assets/${options.sceneName}/lightmap`; + const operation: LightFXHostOperation = { + id: operationId, + target: options.target, + sceneName: options.sceneName, + timeoutMs: options.timeoutMs, + workspace, + inputPath: join(tmpDir, 'lfx.in'), + outputDir, + targetDir, + targetUrl, + refreshUrl: `db://assets/${options.sceneName}`, + inputBytes: 0, + inputWritePromise: Promise.resolve(), + state: 'accepting-input', + controller: new AbortController(), + runner: new LightFXProcess(), + assets: null, + cleanupPromise: null, + expiryTimer: null, + terminalState: null, + }; + + // Reserve the global operation before the first asynchronous filesystem call. + this.operation = operation; + try { + await ensureDir(tmpDir); + await ensureDir(outputDir); + await outputFile(operation.inputPath, Buffer.alloc(0)); + await this.copyTextureSources(options.textureSources, tmpDir); + this.armExpiry(operation); + return { operationId }; + } catch (error) { + this.decideTerminalState(operation, 'rolled-back'); + await this.cleanup(operation, false); + throw error; + } + } + + public async appendInput(options: IAppendLightFXInputOptions): Promise { + const operation = this.requireActiveOperation(options.operationId); + if (operation.state !== 'accepting-input') { + throw new Error('LightFX input can only be appended before the bake starts.'); + } + const chunk = this.decodeBase64Chunk(options.chunkBase64); + if (!chunk.length) { + return; + } + if (operation.inputBytes + chunk.length > MAX_INPUT_BYTES) { + this.decideTerminalState(operation, 'rolled-back'); + await this.cleanup(operation, true); + throw new Error('LightFX input exceeds the 1 GiB limit.'); + } + const writePromise = operation.inputWritePromise.then(async () => { + this.throwIfTerminated(operation); + await appendFile(operation.inputPath, chunk); + operation.inputBytes += chunk.length; + }); + operation.inputWritePromise = writePromise.catch(() => undefined); + await writePromise; + } + + public async run(options: IRunLightFXBakeOptions): Promise { + const operation = this.requireActiveOperation(options.operationId); + if (operation.state !== 'accepting-input') { + throw new Error('LightFX bake has already started.'); + } + operation.state = 'running'; + + try { + await operation.inputWritePromise; + this.throwIfTerminated(operation); + if (!operation.inputBytes) { + throw new Error('LightFX input is empty.'); + } + await operation.runner.run({ + cwd: operation.workspace, + timeoutMs: operation.timeoutMs, + signal: operation.controller.signal, + onLog: (line) => console.log(`[LightFX] ${line}`), + }); + this.throwIfTerminated(operation); + const result = decodeLightFXOutput(await readFile(join(operation.outputDir, 'lfx.out'))); + const textureUrls = operation.target === 'lightmap' + ? await this.stageLightmapAssets(operation) + : []; + this.throwIfTerminated(operation); + operation.state = 'awaiting-commit'; + return { result, textureUrls }; + } catch (error) { + const terminalError = operation.terminalState === 'cancelled' || operation.terminalState === 'expired' + ? this.terminalOperationError(operation.terminalState) + : error; + if (!operation.terminalState) { + this.decideTerminalState(operation, 'rolled-back'); + } + await this.cleanup(operation, true); + throw terminalError; + } + } + + public async commit(options: ILightFXOperationOptions): Promise { + this.validateOperationId(options.operationId); + const completedState = this.completedOperations.get(options.operationId); + if (completedState === 'committed') { + return; + } + if (completedState) { + throw this.cannotCommitError(completedState); + } + const operation = this.requireOperation(options.operationId); + if (operation.terminalState === 'committed') { + await operation.cleanupPromise; + return; + } + if (operation.terminalState) { + throw this.cannotCommitError(operation.terminalState); + } + if (operation.state !== 'awaiting-commit') { + throw new Error('LightFX bake cannot be committed before it finishes.'); + } + // This synchronous decision is the linearization point shared with cancellation and expiry. + this.decideTerminalState(operation, 'committed'); + try { + await this.cleanup(operation, false); + } catch (error) { + // The scene and generated assets are already committed. A temporary-workspace cleanup + // failure must not turn a successful bake into a rollback request from the Scene side. + console.warn('[LightFX] Failed to remove the completed bake workspace:', error); + } + } + + public async rollback(options: ILightFXOperationOptions): Promise { + this.validateOperationId(options.operationId); + const completedState = this.completedOperations.get(options.operationId); + if (completedState === 'committed') { + throw new Error('A committed LightFX bake cannot be rolled back.'); + } + if (completedState) { + return; + } + const operation = this.requireOperation(options.operationId); + if (operation.terminalState === 'committed') { + throw new Error('A committed LightFX bake cannot be rolled back.'); + } + if (operation.terminalState && operation.terminalState !== 'rolled-back') { + if (operation.cleanupPromise) { + await operation.cleanupPromise; + } + return; + } + if (!operation.terminalState) { + this.decideTerminalState(operation, 'rolled-back'); + } + operation.controller.abort(); + await operation.runner.cancel(); + await this.cleanup(operation, true); + } + + public async cancel(): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }> { + const operation = this.operation; + if (!operation) { + return { cancelled: false, target: null }; + } + if (operation.terminalState) { + return { cancelled: false, target: null }; + } + this.decideTerminalState(operation, 'cancelled'); + operation.controller.abort(); + await operation.runner.cancel(); + // While run() owns output staging, its catch path must also own rollback. Cleaning here + // could otherwise restore the backup concurrently with stageLightmapAssets(). + if (operation.state !== 'running') { + await this.cleanup(operation, true); + } + return { cancelled: true, target: operation.target }; + } + + public async removeLightmapAssets(options: IRemoveLightmapAssetsOptions): Promise { + if (this.operation) { + throw new Error(`A ${this.operation.target} LightFX bake is already in progress.`); + } + this.validateSceneName(options.sceneName); + const targetDir = join(this.queryAssetRoot(), options.sceneName, 'lightmap'); + await remove(targetDir); + await assetManager.refreshAsset(`db://assets/${options.sceneName}`); + } + + /** Releases an abandoned operation when its owning Scene host shuts down. */ + public async dispose(): Promise { + const operation = this.operation; + if (!operation) { + return; + } + if (operation.terminalState === 'committed') { + await operation.cleanupPromise; + return; + } + if (!operation.terminalState) { + this.decideTerminalState(operation, 'cancelled'); + } + operation.controller.abort(); + await operation.runner.cancel(); + await this.cleanup(operation, true); + } + + private validateBeginOptions(options: IBeginLightFXBakeOptions): void { + if (!options || (options.target !== 'light-probe' && options.target !== 'lightmap')) { + throw new Error('Invalid LightFX bake target.'); + } + this.validateSceneName(options.sceneName); + if (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 1_000 || options.timeoutMs > 3_600_000) { + throw new Error('LightFX timeout must be an integer between 1000 and 3600000 milliseconds.'); + } + if (!Array.isArray(options.textureSources) || options.textureSources.length > MAX_TEXTURE_SOURCES) { + throw new Error('Invalid LightFX texture source list.'); + } + } + + private validateSceneName(sceneName: string): void { + if ( + typeof sceneName !== 'string' + || !sceneName.trim() + || sceneName === '.' + || sceneName === '..' + || /[<>:"/\\|?*\0]/.test(sceneName) + || /[. ]$/.test(sceneName) + ) { + throw new Error('Invalid LightFX scene name.'); + } + } + + private async copyTextureSources(textureSources: ILightFXTextureSource[], textureDir: string): Promise { + const fileNames = new Set(); + for (const texture of textureSources) { + const resolved = await this.resolveHostTextureSource(texture); + if (!resolved) { + throw new Error(`LightFX texture source is unavailable: ${texture.uuid}`); + } + if (texture.fileName !== resolved.fileName || basename(texture.fileName) !== texture.fileName) { + throw new Error(`Invalid LightFX texture file name: ${texture.fileName}`); + } + if (fileNames.has(texture.fileName)) { + continue; + } + fileNames.add(texture.fileName); + await copy(resolved.sourcePath, join(textureDir, texture.fileName)); + } + } + + private async resolveHostTextureSource( + options: IResolveLightFXTextureSourceOptions, + ): Promise { + if (!options || typeof options.uuid !== 'string') { + throw new Error('Invalid LightFX texture UUID.'); + } + const uuid = Utils.UUID.decompressUUID(options.uuid); + if (!Utils.UUID.isUUID(uuid)) { + throw new Error('Invalid LightFX texture UUID.'); + } + if ( + typeof options.nativeExtension !== 'string' + || !/^(?:\.[a-zA-Z0-9_-]+)?$/.test(options.nativeExtension) + ) { + throw new Error('Invalid LightFX texture native extension.'); + } + + let sourcePath: string | null = null; + if (uuid.includes('@')) { + const projectRoot = dirname(this.queryAssetRoot()); + sourcePath = join( + projectRoot, + 'library', + uuid.slice(0, 2), + `${uuid}${options.nativeExtension}`, + ); + } else { + sourcePath = assetManager.queryPath(uuid) || null; + } + if (!sourcePath || !(await pathExists(sourcePath))) { + return null; + } + const safeUuid = uuid.replace(/[^a-zA-Z0-9_.-]/g, '_'); + return { + sourcePath, + fileName: `${safeUuid}-${basename(sourcePath)}`, + }; + } + + private decodeBase64Chunk(value: string): Buffer { + if ( + typeof value !== 'string' + || value.length > MAX_INPUT_CHUNK_BASE64_LENGTH + || value.length % 4 !== 0 + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) + ) { + throw new Error('Invalid base64 LightFX input chunk.'); + } + return Buffer.from(value, 'base64'); + } + + private async stageLightmapAssets(operation: LightFXHostOperation): Promise { + const files = (await readdir(operation.outputDir)) + .filter((file) => file.toLowerCase().endsWith('.png')) + .sort((a, b) => a.localeCompare(b)); + if (!files.length) { + throw new Error('LightFX did not produce any lightmap textures.'); + } + + const assets = new LightmapAssetTransaction(operation.targetDir, operation.workspace); + operation.assets = assets; + await assets.prepare(); + for (const file of files) { + if (basename(file) !== file) { + throw new Error(`Invalid LightFX output file name: ${file}`); + } + await copy(join(operation.outputDir, file), join(operation.targetDir, file), { overwrite: true }); + await assets.preserveMeta(file); + } + await assetManager.refreshAsset(operation.targetUrl); + + for (const file of files) { + this.throwIfTerminated(operation); + const url = `${operation.targetUrl}/${file}`; + const uuid = await this.waitForAsset(operation, url, Math.min(operation.timeoutMs, 60_000)); + await this.disableAlphaFix(uuid); + } + return files.map((file) => `${operation.targetUrl}/${file}`); + } + + private async waitForAsset(operation: LightFXHostOperation, url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + do { + this.throwIfTerminated(operation); + const uuid = assetManager.queryUUID(url); + if (uuid) { + return uuid; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } while (Date.now() < deadline); + throw new Error(`Lightmap texture import timed out: ${url}`); + } + + private async disableAlphaFix(uuid: string): Promise { + const meta = assetManager.queryAssetMeta(uuid) as any; + if (!meta) { + throw new Error(`Lightmap texture metadata is unavailable: ${uuid}`); + } + if (meta.userData?.fixAlphaTransparencyArtifacts === false) { + return; + } + meta.userData ??= {}; + meta.userData.fixAlphaTransparencyArtifacts = false; + await assetManager.saveAssetMeta(uuid, meta); + } + + private queryAssetRoot(): string { + const assetRoot = assetManager.queryPath('db://assets'); + if (!assetRoot) { + throw new Error('The db://assets directory is unavailable.'); + } + return assetRoot; + } + + private requireOperation(operationId: string): LightFXHostOperation { + this.validateOperationId(operationId); + const operation = this.operation; + if (!operation || operation.id !== operationId) { + throw new Error(`Unknown LightFX operation: ${operationId}`); + } + return operation; + } + + private requireActiveOperation(operationId: string): LightFXHostOperation { + this.validateOperationId(operationId); + const operation = this.operation; + if (operation?.id === operationId) { + this.throwIfTerminated(operation); + return operation; + } + const terminalState = this.completedOperations.get(operationId); + if (terminalState) { + throw this.terminalOperationError(terminalState); + } + throw new Error(`Unknown LightFX operation: ${operationId}`); + } + + private validateOperationId(operationId: string): void { + if (typeof operationId !== 'string' || !operationId) { + throw new Error('Invalid LightFX operation id.'); + } + } + + private cannotCommitError(state: Exclude): Error { + return new Error(`LightFX bake was ${state} and cannot be committed.`); + } + + private terminalOperationError(state: OperationTerminalState): Error { + switch (state) { + case 'cancelled': + return new Error('LightFX bake was cancelled.'); + case 'expired': + return new Error('LightFX bake timed out.'); + case 'rolled-back': + return new Error('LightFX bake was rolled back.'); + case 'committed': + return new Error('LightFX bake has already completed.'); + } + } + + private decideTerminalState(operation: LightFXHostOperation, state: OperationTerminalState): void { + if (operation.terminalState) { + if (operation.terminalState === state) { + return; + } + throw new Error(`LightFX bake was already ${operation.terminalState}.`); + } + operation.terminalState = state; + } + + private throwIfTerminated(operation: LightFXHostOperation): void { + if (operation.terminalState) { + throw this.terminalOperationError(operation.terminalState); + } + if (operation.controller.signal.aborted) { + throw new Error('LightFX bake was cancelled.'); + } + } + + private armExpiry(operation: LightFXHostOperation): void { + operation.expiryTimer = setTimeout(() => { + if (this.operation !== operation || operation.terminalState) { + return; + } + this.decideTerminalState(operation, 'expired'); + operation.controller.abort(); + void (async () => { + await operation.runner.cancel(); + // A running operation serializes rollback through run()'s catch path. + if (operation.state !== 'running') { + await this.cleanup(operation, true); + } + })().catch((error) => console.error('[LightFX] Failed to clean up an expired bake:', error)); + }, operation.timeoutMs); + operation.expiryTimer.unref?.(); + } + + private cleanup(operation: LightFXHostOperation, rollbackAssets: boolean): Promise { + if (operation.cleanupPromise) { + return operation.cleanupPromise; + } + const cleanupPromise = (async () => { + // All terminal paths converge here. Do not remove the workspace while an input chunk + // that was accepted before cancellation, rollback, expiry or disposal is still writing. + await operation.inputWritePromise; + if (operation.expiryTimer) { + clearTimeout(operation.expiryTimer); + operation.expiryTimer = null; + } + if (rollbackAssets && operation.assets) { + // Do not remove the workspace: it owns the only backup from which rollback can be + // retried when either restoration or the following Asset DB refresh fails. + await operation.assets.rollback(); + await assetManager.refreshAsset(operation.refreshUrl); + } + let cleanupError: unknown; + try { + await remove(operation.workspace); + } catch (error) { + cleanupError = error; + if (rollbackAssets) { + throw error; + } + } + if (!operation.terminalState) { + throw new Error('LightFX operation cleanup requires a terminal state.'); + } + this.rememberCompletedOperation(operation.id, operation.terminalState); + if (this.operation === operation) { + this.operation = null; + } + if (cleanupError) { + throw cleanupError; + } + })().catch((error) => { + // A failed rollback must remain active and retryable, with its backup workspace intact. + if (operation.cleanupPromise === cleanupPromise) { + operation.cleanupPromise = null; + } + throw error; + }); + operation.cleanupPromise = cleanupPromise; + return cleanupPromise; + } + + private rememberCompletedOperation(operationId: string, state: OperationTerminalState): void { + this.completedOperations.set(operationId, state); + if (this.completedOperations.size > MAX_REMEMBERED_OPERATIONS) { + const oldest = this.completedOperations.keys().next().value as string | undefined; + if (oldest) { + this.completedOperations.delete(oldest); + } + } + } +} + +export const lightFXBakeHost = new LightFXBakeHost(); diff --git a/src/core/scene/main-process/lightfx-bake-renderer.ts b/src/core/scene/main-process/lightfx-bake-renderer.ts new file mode 100644 index 000000000..0b2300cb0 --- /dev/null +++ b/src/core/scene/main-process/lightfx-bake-renderer.ts @@ -0,0 +1,118 @@ +import type { RemoteSocket } from 'socket.io'; +import type { DefaultEventsMap } from 'socket.io/dist/typed-events'; +import { SCENE_RENDERER_ROOM, socketService } from '../../../server/socket'; + +type LightFXModule = 'LightProbeBake' | 'LightmapBake'; +type LightFXMethod = 'bake' | 'clearBake' | 'cancel'; + +interface RendererSocketData { + sceneUrl?: string; + sceneRendererVisible?: boolean; +} + +interface LightFXResponse { + result?: T; + sceneUrl?: string; + error?: string; +} + +type RendererSocket = RemoteSocket; + +function selectActiveRenderer(sockets: RendererSocket[]): RendererSocket { + const loaded = sockets.filter((socket) => Boolean(socket.data.sceneUrl)); + const visible = loaded.filter((socket) => socket.data.sceneRendererVisible === true); + if (visible.length === 1) return visible[0]; + if (visible.length > 1) { + throw new Error('Multiple visible scene renderers are open. Close the duplicate scene views and retry.'); + } + if (sockets.some((socket) => socket.data.sceneRendererVisible === true)) { + throw new Error('The visible scene renderer has not finished loading a scene. Wait for it and retry.'); + } + + const candidates = loaded.filter((socket) => socket.data.sceneRendererVisible !== false); + if (candidates.length === 1) return candidates[0]; + if (candidates.length === 0) { + throw new Error('No loaded scene renderer is currently visible. Activate the target scene tab and retry.'); + } + throw new Error('Multiple scene renderers are open. Activate the target scene tab and retry.'); +} + +function requestRenderer( + socket: RendererSocket, + module: LightFXModule, + method: LightFXMethod, + args: unknown[], + timeoutMs: number, +): Promise { + const sceneUrl = socket.data.sceneUrl || ''; + return new Promise((resolve, reject) => { + socket.timeout(timeoutMs).emit( + 'scene:invoke-lightfx', + { sceneUrl, module, method, args }, + (error: Error | null, response?: LightFXResponse) => { + if (error) { + reject(new Error(`The active scene renderer did not complete the LightFX request. (${error.message})`)); + } else if (response?.error) { + reject(new Error(response.error)); + } else if (!response || !Object.prototype.hasOwnProperty.call(response, 'result')) { + reject(new Error('The active scene renderer returned an invalid LightFX response.')); + } else if (method !== 'cancel' && response.sceneUrl !== sceneUrl) { + reject(new Error( + `The active scene changed during the LightFX request: expected ${sceneUrl}, ` + + `got ${response.sceneUrl || 'unknown'}.`, + )); + } else { + resolve(response.result as T); + } + }, + ); + }); +} + +class LightFXBakeRenderer { + private activeBakeRendererId: string | null = null; + + async invoke( + module: LightFXModule, + method: LightFXMethod, + args: unknown[], + timeoutMs: number, + fallback: () => Promise, + trackBake = false, + ): Promise { + const io = socketService.io; + if (!io) return fallback(); + const sockets = await io.in(SCENE_RENDERER_ROOM).fetchSockets() as RendererSocket[]; + if (sockets.length === 0) return fallback(); + + const renderer = selectActiveRenderer(sockets); + if (trackBake && this.activeBakeRendererId) { + throw new Error('A LightFX bake is already in progress in the scene renderer.'); + } + if (trackBake) this.activeBakeRendererId = renderer.id; + try { + return await requestRenderer(renderer, module, method, args, timeoutMs); + } finally { + if (trackBake && this.activeBakeRendererId === renderer.id) { + this.activeBakeRendererId = null; + } + } + } + + async cancel(fallback: () => Promise, timeoutMs = 30_000): Promise { + const io = socketService.io; + if (!io) return fallback(); + const sockets = await io.in(SCENE_RENDERER_ROOM).fetchSockets() as RendererSocket[]; + if (sockets.length === 0) return fallback(); + + const renderer = this.activeBakeRendererId + ? sockets.find((socket) => socket.id === this.activeBakeRendererId) + : selectActiveRenderer(sockets); + if (!renderer) { + throw new Error('The scene renderer running the LightFX bake is no longer connected.'); + } + return requestRenderer(renderer, 'LightProbeBake', 'cancel', [], timeoutMs); + } +} + +export const lightFXBakeRenderer = new LightFXBakeRenderer(); diff --git a/src/core/scene/scene-process/service/baking/lightfx/asset-transaction.ts b/src/core/scene/main-process/lightfx/asset-transaction.ts similarity index 53% rename from src/core/scene/scene-process/service/baking/lightfx/asset-transaction.ts rename to src/core/scene/main-process/lightfx/asset-transaction.ts index 728b06e25..e343f1891 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/asset-transaction.ts +++ b/src/core/scene/main-process/lightfx/asset-transaction.ts @@ -1,6 +1,7 @@ import { copy, ensureDir, pathExists, remove } from 'fs-extra'; import { dirname, join } from 'path'; +/** Replaces one lightmap directory while retaining enough state for a later rollback. */ export class LightmapAssetTransaction { private readonly backupDir: string; private hadTarget = false; @@ -10,27 +11,40 @@ export class LightmapAssetTransaction { this.backupDir = join(workspace, 'lightmap-asset-backup'); } - async prepare(): Promise { + public async prepare(): Promise { + if (this.prepared) { + return; + } this.hadTarget = await pathExists(this.targetDir); - if (this.hadTarget) await copy(this.targetDir, this.backupDir); + if (this.hadTarget) { + await copy(this.targetDir, this.backupDir); + } await remove(this.targetDir); await ensureDir(this.targetDir); this.prepared = true; } - async rollback(): Promise { - if (!this.prepared) return; + public async rollback(): Promise { + if (!this.prepared) { + return; + } await remove(this.targetDir); if (this.hadTarget) { await ensureDir(dirname(this.targetDir)); await copy(this.backupDir, this.targetDir); } + // Keep the transaction retryable until the previous asset directory is fully restored. + this.prepared = false; } - async preserveMeta(relativeAssetPath: string): Promise { - if (!this.hadTarget) return; + public async preserveMeta(relativeAssetPath: string): Promise { + if (!this.hadTarget) { + return; + } const metaPath = `${relativeAssetPath}.meta`; const source = join(this.backupDir, metaPath); - if (await pathExists(source)) await copy(source, join(this.targetDir, metaPath)); + if (await pathExists(source)) { + await copy(source, join(this.targetDir, metaPath)); + } } } diff --git a/src/core/scene/main-process/lightfx/output.ts b/src/core/scene/main-process/lightfx/output.ts new file mode 100644 index 000000000..f49d79263 --- /dev/null +++ b/src/core/scene/main-process/lightfx/output.ts @@ -0,0 +1,121 @@ +import type { ILightFXResult } from '../../common/lightfx-host'; + +const LIGHTFX_OUTPUT_VERSIONS = new Set([0x2000, 0x2002, 0x2003, 0x3730]); +const MAX_COLLECTION_LENGTH = 10_000_000; + +const enum LightFXChunk { + EOF = 0, + TERRAIN = 1, + MESH = 2, + LIGHT_PROBE = 4, +} + +class LightFXOutputReader { + private readonly view: DataView; + private cursor = 0; + + constructor(input: Uint8Array) { + this.view = new DataView(input.buffer, input.byteOffset, input.byteLength); + } + + public get remaining(): number { + return this.view.byteLength - this.cursor; + } + + public readInt32(): number { + this.ensure(4); + const value = this.view.getInt32(this.cursor, true); + this.cursor += 4; + return value; + } + + public readFloat(): number { + this.ensure(4); + const value = this.view.getFloat32(this.cursor, true); + this.cursor += 4; + if (!Number.isFinite(value)) { + throw new Error('LightFX output contains a non-finite float.'); + } + return value; + } + + public readFloats(count: number): number[] { + this.validateCount(count); + return Array.from({ length: count }, () => this.readFloat()); + } + + public readCount(label: string): number { + const count = this.readInt32(); + if (count < 0 || count > MAX_COLLECTION_LENGTH) { + throw new Error(`Invalid LightFX ${label} count: ${count}.`); + } + return count; + } + + private validateCount(count: number): void { + if (!Number.isInteger(count) || count < 0 || count > MAX_COLLECTION_LENGTH) { + throw new Error(`Invalid LightFX array length: ${count}.`); + } + } + + private ensure(size: number): void { + if (this.cursor + size > this.view.byteLength) { + throw new Error('LightFX output is truncated.'); + } + } +} + +export function decodeLightFXOutput(input: Uint8Array): ILightFXResult { + const reader = new LightFXOutputReader(input); + const result: ILightFXResult = { + version: reader.readInt32(), + meshes: [], + terrains: [], + probes: [], + }; + if (!LIGHTFX_OUTPUT_VERSIONS.has(result.version)) { + throw new Error(`Unsupported LightFX output version: 0x${result.version.toString(16)}.`); + } + + while (reader.remaining > 0) { + const chunk = reader.readInt32(); + if (chunk === LightFXChunk.EOF) { + return result; + } + if (chunk === LightFXChunk.TERRAIN) { + const id = reader.readInt32(); + const count = reader.readCount('terrain'); + for (let i = 0; i < count; i++) { + result.terrains.push({ + id, + blockId: reader.readInt32(), + index: reader.readInt32(), + offset: reader.readFloats(2), + scale: reader.readFloats(2), + }); + } + } else if (chunk === LightFXChunk.MESH) { + const count = reader.readCount('mesh'); + for (let i = 0; i < count; i++) { + result.meshes.push({ + id: reader.readInt32(), + index: reader.readInt32(), + offset: reader.readFloats(2), + scale: reader.readFloats(2), + }); + } + } else if (chunk === LightFXChunk.LIGHT_PROBE) { + const count = reader.readCount('light probe'); + for (let i = 0; i < count; i++) { + result.probes.push({ + position: reader.readFloats(3), + normal: reader.readFloats(3), + coefficients: reader.readFloats(reader.readCount('coefficient')), + }); + } + } else { + throw new Error(`Unknown LightFX output chunk: ${chunk}.`); + } + } + throw new Error('LightFX output has no EOF chunk.'); +} diff --git a/src/core/scene/main-process/lightfx/process.ts b/src/core/scene/main-process/lightfx/process.ts new file mode 100644 index 000000000..a81c6e40b --- /dev/null +++ b/src/core/scene/main-process/lightfx/process.ts @@ -0,0 +1,141 @@ +import type { ChildProcess } from 'child_process'; +import { spawn } from 'child_process'; +import { existsSync } from 'fs'; +import { createServer, type Server as HttpServer } from 'http'; +import { join } from 'path'; +import { GlobalPaths } from '../../../../global'; + +// LightFX embeds a Socket.IO 2.x client. Keep the legacy server in the Node host so the browser +// Scene runtime never has to load either Socket.IO or Node's networking/process modules. +const createLegacySocketServer = require('socket.io-v2') as (server: HttpServer, options: object) => any; + +export interface LightFXProcessOptions { + cwd: string; + timeoutMs: number; + signal?: AbortSignal; + onLog?: (message: string) => void; + onProgress?: (progress: unknown) => void; +} + +export class LightFXProcess { + private child: ChildProcess | null = null; + private http: HttpServer | null = null; + private io: any = null; + private settled = false; + private closePromise: Promise | null = null; + + public async run(options: LightFXProcessOptions): Promise { + if (this.child || this.io) { + throw new Error('LightFX process is already running.'); + } + if (options.signal?.aborted) { + throw new Error('LightFX bake was cancelled.'); + } + + const executable = join( + GlobalPaths.staticDir, + 'tools', + 'lightmap-tools', + process.platform === 'win32' ? 'LightFX.exe' : 'LightFX', + ); + if (!existsSync(executable)) { + throw new Error(`LightFX executable was not found: ${executable}`); + } + + this.settled = false; + this.closePromise = null; + await new Promise((resolve, reject) => { + let timer: NodeJS.Timeout; + const finish = async (error?: unknown): Promise => { + if (this.settled) { + return; + } + this.settled = true; + clearTimeout(timer); + options.signal?.removeEventListener('abort', abort); + await this.close(); + if (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } else { + resolve(); + } + }; + const fail = (error: unknown): void => { void finish(error); }; + const succeed = (): void => { void finish(); }; + const abort = (): void => fail(new Error('LightFX bake was cancelled.')); + + timer = setTimeout(() => fail(new Error('LightFX bake timed out.')), options.timeoutMs); + options.signal?.addEventListener('abort', abort, { once: true }); + + void (async () => { + try { + this.http = createServer(); + this.io = createLegacySocketServer(this.http, { + serveClient: false, + transports: ['websocket', 'polling'], + }); + this.io.on('connection', (socket: any) => { + socket.once('Login', () => socket.emit('Start')); + socket.on('Log', (data: unknown) => options.onLog?.(String(data))); + socket.on('Progress', (data: unknown) => options.onProgress?.(data)); + socket.once('Finished', () => { + socket.emit('Stop'); + succeed(); + }); + }); + await new Promise((ready, listenReject) => { + this.http!.once('error', listenReject); + this.http!.listen(0, '127.0.0.1', ready); + }); + const address = this.http.address(); + if (!address || typeof address === 'string') { + throw new Error('LightFX server did not allocate a TCP port.'); + } + this.child = spawn(executable, [`http://127.0.0.1:${address.port}`], { + cwd: options.cwd, + windowsHide: true, + }); + this.child.once('error', fail); + this.child.once('exit', (code, signal) => { + if (!this.settled) { + fail(new Error(`LightFX exited before completion (code=${code}, signal=${signal}).`)); + } + }); + } catch (error) { + fail(error); + } + })(); + }); + } + + public async cancel(): Promise { + const running = Boolean(this.child || this.io); + await this.close(); + return running; + } + + private close(): Promise { + if (this.closePromise) { + return this.closePromise; + } + this.closePromise = (async () => { + if (this.child) { + this.child.kill(); + this.child = null; + } + if (this.io) { + const io = this.io; + this.io = null; + await new Promise((resolve) => io.close(resolve)); + } + if (this.http) { + const http = this.http; + this.http = null; + if (http.listening) { + await new Promise((resolve) => http.close(() => resolve())); + } + } + })(); + return this.closePromise; + } +} diff --git a/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts b/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts index e4e33130b..3dfe8dbee 100644 --- a/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts +++ b/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts @@ -1,14 +1,31 @@ import type { IPublicLightProbeBakeService, IPublicLightmapBakeService } from '../../common'; +import { lightFXBakeRenderer } from '../lightfx-bake-renderer'; import { Rpc } from '../rpc'; export const LightProbeBakeProxy: IPublicLightProbeBakeService = { - bake: (options) => Rpc.getInstance().request('LightProbeBake', 'bake', [options]), - clearBake: (options) => Rpc.getInstance().request('LightProbeBake', 'clearBake', [options]), - cancel: () => Rpc.getInstance().request('LightProbeBake', 'cancel'), + bake: (options) => lightFXBakeRenderer.invoke( + 'LightProbeBake', 'bake', [options], (options.timeoutMs ?? 600_000) + 30_000, + () => Rpc.getInstance().request('LightProbeBake', 'bake', [options]), true, + ), + clearBake: (options) => lightFXBakeRenderer.invoke( + 'LightProbeBake', 'clearBake', [options], 120_000, + () => Rpc.getInstance().request('LightProbeBake', 'clearBake', [options]), + ), + cancel: () => lightFXBakeRenderer.cancel( + () => Rpc.getInstance().request('LightProbeBake', 'cancel'), + ), }; export const LightmapBakeProxy: IPublicLightmapBakeService = { - bake: (options) => Rpc.getInstance().request('LightmapBake', 'bake', [options]), - clearBake: (options) => Rpc.getInstance().request('LightmapBake', 'clearBake', [options]), - cancel: () => Rpc.getInstance().request('LightmapBake', 'cancel'), + bake: (options) => lightFXBakeRenderer.invoke( + 'LightmapBake', 'bake', [options], (options.timeoutMs ?? 600_000) + 30_000, + () => Rpc.getInstance().request('LightmapBake', 'bake', [options]), true, + ), + clearBake: (options) => lightFXBakeRenderer.invoke( + 'LightmapBake', 'clearBake', [options], 120_000, + () => Rpc.getInstance().request('LightmapBake', 'clearBake', [options]), + ), + cancel: () => lightFXBakeRenderer.cancel( + () => Rpc.getInstance().request('LightmapBake', 'cancel'), + ), }; diff --git a/src/core/scene/main-process/scene-host-local-executor.ts b/src/core/scene/main-process/scene-host-local-executor.ts index 16bccb720..8368d8156 100644 --- a/src/core/scene/main-process/scene-host-local-executor.ts +++ b/src/core/scene/main-process/scene-host-local-executor.ts @@ -5,6 +5,7 @@ import { ProcessRPC } from '../process-rpc'; import { sceneConfigInstance } from '../scene-configs'; import { referenceImageFiles } from './reference-image-files'; import { referenceImageStore } from './reference-image-store'; +import { lightFXBakeHost } from './lightfx-bake-host'; export interface SceneHostModules { assetManager: typeof assetManager; @@ -13,6 +14,7 @@ export interface SceneHostModules { i18n: typeof i18n; referenceImageFiles: typeof referenceImageFiles; referenceImageStore: typeof referenceImageStore; + lightFXBakeHost: typeof lightFXBakeHost; } const defaultSceneHostModules: SceneHostModules = { @@ -23,6 +25,8 @@ const defaultSceneHostModules: SceneHostModules = { // Feature-owned Node modules: external file reads and serialized local configuration writes. referenceImageFiles, referenceImageStore, + // Native LightFX execution, filesystem staging and Asset DB transactions stay in Node. + lightFXBakeHost, }; /** Registers the default host modules with the specified Scene RPC transport. */ @@ -51,5 +55,8 @@ export class SceneHostLocalExecutor { public dispose(): void { this.rpc.dispose(); + void this.modules.lightFXBakeHost.dispose().catch((error) => { + console.error('[Node] Failed to dispose the LightFX bake host:', error); + }); } } diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index d96be91dc..c3248d97f 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -2,6 +2,7 @@ import * as EditorExtends from '../../engine/editor-extends'; import { Rpc } from './rpc'; import { serviceManager } from './service/service-manager'; import { Service as DecoratorService } from './service/core/decorator'; +import { ServiceEvents } from './service/core'; import { ReferenceImageService } from './service/reference-image'; import { messageManager } from './service/message'; import { initLocalI18n } from './i18n'; @@ -231,6 +232,31 @@ async function setupBrowserInvokeChannel(serverURL: string) { return; } const socket = io(serverURL); + let rendererVisible: boolean | undefined; + const updateRendererVisibility = (visible: boolean) => { + rendererVisible = visible; + socket.emit('scene-renderer:visibility', { visible }); + }; + // Pink retains a hidden, empty Scene Webview for preloading. Track the + // host-reported visibility so Node-side tools select the displayed scene. + window.addEventListener('message', (event: MessageEvent) => { + const message = event.data; + if (message?.kind === 'event' + && message.event === 'editor:visibility-changed' + && typeof message.data?.visible === 'boolean') { + updateRendererVisibility(message.data.visible); + } + }); + ServiceEvents.on('scene-view:visibility-changed', updateRendererVisibility); + const querySceneUrl = async (): Promise => { + const current = await DecoratorService.Editor.queryCurrent(); + return (current as any)?.__identifier__?.assetUrl ?? (current as any)?.assetUrl ?? ''; + }; + let rendererSceneUrl = ''; + const updateRendererScene = (sceneUrl: string) => { + rendererSceneUrl = sceneUrl; + socket.emit('scene-renderer:scene', { sceneUrl }); + }; const invoke = (module: string, method: string, args?: any[]) => { try { const svc = (DecoratorService as any)[module]; @@ -246,12 +272,58 @@ async function setupBrowserInvokeChannel(serverURL: string) { invoke(msg.module, msg.method, msg.args); } }); + socket.on('scene:invoke-lightfx', async ( + msg: { + sceneUrl?: string; + module?: 'LightProbeBake' | 'LightmapBake'; + method?: 'bake' | 'clearBake' | 'cancel'; + args?: unknown[]; + }, + reply: (response: { result?: unknown; sceneUrl?: string; error?: string }) => void, + ) => { + try { + const methods = msg?.module === 'LightProbeBake' + ? new Set(['bake', 'clearBake', 'cancel']) + : msg?.module === 'LightmapBake' + ? new Set(['bake', 'clearBake', 'cancel']) + : null; + if (!methods?.has(msg.method || '')) { + throw new Error('Invalid LightFX scene request.'); + } + + const currentSceneUrl = await querySceneUrl(); + if (msg.method !== 'cancel' && (!currentSceneUrl || currentSceneUrl !== msg.sceneUrl)) { + throw new Error( + `The selected scene renderer is not displaying the requested scene: ${msg.sceneUrl || 'unknown'}.`, + ); + } + + const service = (DecoratorService as any)[msg.module!]; + const result = await service[msg.method!](...(msg.args || [])); + const finalSceneUrl = await querySceneUrl().catch(() => ''); + if (finalSceneUrl) updateRendererScene(finalSceneUrl); + reply({ result, sceneUrl: finalSceneUrl }); + } catch (error) { + reply({ error: error instanceof Error ? error.message : String(error) }); + } + }); // Reconcile feature-local runtime state after first connection or reconnect. // Reference images need this because their Sprite objects are not persisted with configuration. socket.on('connect', () => { invoke('Engine', 'syncDesignResolution', []); invoke('ReferenceImage', 'syncFromAuthority', []); + socket.emit('scene-renderer:register', { + sceneUrl: rendererSceneUrl, + visible: rendererVisible, + }); + void querySceneUrl().then(updateRendererScene).catch(() => undefined); }); + const reportRendererScene = () => { + void querySceneUrl().then(updateRendererScene).catch(() => updateRendererScene('')); + }; + ServiceEvents.on('editor:open', reportRendererScene); + ServiceEvents.on('editor:reload', reportRendererScene); + ServiceEvents.on('editor:close', () => updateRendererScene('')); } catch (e) { console.warn('[engine-bootstrap] setup browser-invoke channel failed:', e); } diff --git a/src/core/scene/scene-process/service/baking/lightfx/baker.ts b/src/core/scene/scene-process/service/baking/lightfx/baker.ts index eb9404882..643abab41 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/baker.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/baker.ts @@ -1,29 +1,74 @@ import { Scene } from 'cc'; -import { ensureDir, outputFile, readFile, remove } from 'fs-extra'; -import { dirname, join } from 'path'; -import { Rpc } from '../../../rpc'; -import { decodeLightFXOutput, encodeLightFXInput } from './format'; +import { encodeLightFXBase64 } from './buffer'; +import { encodeLightFXInput } from './format'; import { LightFXExporter, LightFXExport } from './exporter'; -import { LightFXProcess } from './process'; +import { lightFXBakeHost } from './host'; import { LightFXBakeTarget, LightFXResult, LightFXSettings } from './types'; -export interface LightFXBakeOutput extends LightFXExport { result: LightFXResult; workspace: string; outputDir: string } +const INPUT_CHUNK_SIZE = 512 * 1024; + +export interface LightFXBakeOutput extends LightFXExport { + result: LightFXResult; + operationId: string; + textureUrls: string[]; +} class LightFXCoordinator { - private target: LightFXBakeTarget | null = null; private controller: AbortController | null = null; private runner: LightFXProcess | null = null; + private target: LightFXBakeTarget | null = null; + get activeTarget(): LightFXBakeTarget | null { return this.target; } + async bake(scene: Scene, target: LightFXBakeTarget, settings: LightFXSettings, timeoutMs: number): Promise { - if (this.target) throw new Error(`A ${this.target} LightFX bake is already in progress.`); this.target = target; this.controller = new AbortController(); this.runner = new LightFXProcess(); - 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 projectRoot = dirname(assetRoot); const workspace = join(projectRoot, 'temp', 'lightfx-bake', `${target}-${Date.now()}-${process.pid}`); const tmpDir = join(workspace, 'tmp'); const outputDir = join(workspace, 'output'); + if (this.target) throw new Error(`A ${this.target} LightFX bake is already in progress.`); + this.target = target; + let operationId: string | undefined; try { - await ensureDir(tmpDir); await ensureDir(outputDir); const exported = await new LightFXExporter(tmpDir, projectRoot).export(scene, target, settings); - await outputFile(join(tmpDir, 'lfx.in'), encodeLightFXInput(exported.world)); - await this.runner.run({ cwd: workspace, timeoutMs, signal: this.controller.signal, onLog: (line) => console.log(`[LightFX] ${line}`) }); - const result = decodeLightFXOutput(await readFile(join(outputDir, 'lfx.out'))); return { ...exported, result, workspace, outputDir }; - } catch (error) { await remove(workspace).catch(() => undefined); throw error; } - finally { this.target = null; this.controller = null; this.runner = null; } + const exported = await new LightFXExporter().export(scene, target, settings); + ({ operationId } = await lightFXBakeHost.begin({ + target, + sceneName: scene.name, + textureSources: exported.textureSources, + timeoutMs, + })); + const input = encodeLightFXInput(exported.world); + for (let offset = 0; offset < input.length; offset += INPUT_CHUNK_SIZE) { + await lightFXBakeHost.appendInput({ + operationId, + chunkBase64: encodeLightFXBase64(input.subarray(offset, Math.min(offset + INPUT_CHUNK_SIZE, input.length))), + }); + } + const output = await lightFXBakeHost.run({ operationId }); + return { ...exported, result: output.result, textureUrls: output.textureUrls, operationId }; + } catch (error) { + if (operationId) await lightFXBakeHost.rollback({ operationId }).catch(() => undefined); + this.target = null; + throw error; + } + } + + async commit(operationId: string): Promise { + try { + await lightFXBakeHost.commit({ operationId }); + } finally { + this.target = null; + } + } + + async rollback(operationId: string): Promise { + try { + await lightFXBakeHost.rollback({ operationId }); + } finally { + this.target = null; + } + } + + removeLightmapAssets(sceneName: string): Promise { + return lightFXBakeHost.removeLightmapAssets({ sceneName }); + } + + async cancel(): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }> { + return lightFXBakeHost.cancel(); } - async cancel(): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }> { const target = this.target; if (!target) return { cancelled: false, target: null }; this.controller?.abort(); await this.runner?.cancel(); return { cancelled: true, target }; } } + export const lightFXCoordinator = new LightFXCoordinator(); diff --git a/src/core/scene/scene-process/service/baking/lightfx/buffer.ts b/src/core/scene/scene-process/service/baking/lightfx/buffer.ts index 4f934f40e..4927972ef 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/buffer.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/buffer.ts @@ -19,7 +19,7 @@ export class LightFXBuffer { writeInts(values: number[]): void { values.forEach((value) => this.writeInt32(value)); } writeFloats(values: number[]): void { values.forEach((value) => this.writeFloat(value)); } writeHeightField(values: Uint16Array): void { this.reserve(values.length * 2); for (const value of values) { this.view.setUint16(this.length, value, true); this.length += 2; } } - writeString(value: string): void { const encoded = Buffer.from(value, 'utf8'); this.writeInt32(encoded.length); this.reserve(encoded.length); this.data.set(encoded, this.length); this.length += encoded.length; } + writeString(value: string): void { const encoded = new TextEncoder().encode(value); this.writeInt32(encoded.length); this.reserve(encoded.length); this.data.set(encoded, this.length); this.length += encoded.length; } readInt8(): number { this.ensure(1); const value = this.view.getInt8(this.cursor); this.cursor++; return value; } readInt32(): number { this.ensure(4); const value = this.view.getInt32(this.cursor, true); this.cursor += 4; return value; } readFloat(): number { this.ensure(4); const value = this.view.getFloat32(this.cursor, true); this.cursor += 4; if (!Number.isFinite(value)) throw new Error('LightFX output contains a non-finite float.'); return value; } @@ -29,3 +29,15 @@ export class LightFXBuffer { private ensure(size: number): void { if (this.cursor + size > this.length) throw new Error('LightFX output is truncated.'); } private reserve(size: number): void { const required = this.length + size; if (required <= this.data.byteLength) return; let capacity = this.data.byteLength || 1; while (capacity < required) capacity *= 2; const next = new Uint8Array(capacity); next.set(this.data); this.data = next; this.view = new DataView(next.buffer); } } + +/** Encode bytes without depending on Node's Buffer. Available in Window and Worker runtimes. */ +export function encodeLightFXBase64(input: Uint8Array): string { + const encode = globalThis.btoa; + if (typeof encode !== 'function') throw new Error('Base64 encoding is unavailable in the scene runtime.'); + const parts: string[] = []; + const chunkSize = 0x8000; + for (let offset = 0; offset < input.length; offset += chunkSize) { + parts.push(String.fromCharCode(...input.subarray(offset, Math.min(offset + chunkSize, input.length)))); + } + return encode(parts.join('')); +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/exporter.ts b/src/core/scene/scene-process/service/baking/lightfx/exporter.ts index 04495292d..9b8b49bf1 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/exporter.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/exporter.ts @@ -1,13 +1,18 @@ import { DirectionalLight, director, gfx, Light, MeshRenderer, MobilityMode, renderer, Scene, SphereLight, SpotLight, Terrain, Texture2D, Vec3 } from 'cc'; -import { basename, join } from 'path'; -import { copy, pathExists } from 'fs-extra'; -import { Rpc } from '../../../rpc'; +import type { ILightFXTextureSource } from '../../../../common/lightfx-host'; +import { lightFXBakeHost } from './host'; import { LightFXBakeTarget, LightFXLight, LightFXMaterial, LightFXMesh, LightFXSettings, LightFXTerrain, LightFXWorld } from './types'; -export interface LightFXExport { world: LightFXWorld; models: MeshRenderer[]; terrains: Terrain[]; stationaryMainLight: boolean } +export interface LightFXExport { + world: LightFXWorld; + models: MeshRenderer[]; + terrains: Terrain[]; + stationaryMainLight: boolean; + textureSources: ILightFXTextureSource[]; +} export class LightFXExporter { - constructor(private readonly textureDir: string, private readonly projectRoot: string) {} + private readonly textureSources = new Map(); async export(scene: Scene, target: LightFXBakeTarget, settings: LightFXSettings): Promise { const world: LightFXWorld = { name: scene.name, settings, meshes: [], terrains: [], lights: [], probes: [], textures: [] }; @@ -31,7 +36,7 @@ export class LightFXExporter { const exposure = hdr ? renderer.scene.Camera.standardExposureValue : 1; for (const light of world.lights) light.color = light.color.map((value) => value * exposure); if (scene.globals.lightProbeInfo.data) for (const probe of scene.globals.lightProbeInfo.data.probes) world.probes.push({ position: [probe.position.x, probe.position.y, probe.position.z], normal: [probe.normal.x, probe.normal.y, probe.normal.z] }); - return { world, models, terrains, stationaryMainLight }; + return { world, models, terrains, stationaryMainLight, textureSources: [...this.textureSources.values()] }; } private exportTerrain(terrain: Terrain): LightFXTerrain { @@ -83,11 +88,15 @@ export class LightFXExporter { const pixelFormat = Texture2D.PixelFormat; if (texture && texture.getPixelFormat() !== pixelFormat.RGBA8888 && texture.getPixelFormat() !== pixelFormat.RGB888) return ''; const image: any = texture?.mipmaps?.[0]; if (!image?._uuid) return ''; - const uuid = String(image._uuid); let source: string | null; - if (uuid.includes('@')) source = join(this.projectRoot, 'library', uuid.slice(0, 2), `${uuid}${image._native ?? ''}`); - else source = await Rpc.getInstance().request('assetManager', 'queryPath', [uuid]) as string | null; - if (!source || !(await pathExists(source))) return ''; - const name = `${uuid.replace(/[^a-zA-Z0-9_.-]/g, '_')}-${basename(source)}`; await copy(source, join(this.textureDir, name)); return name; + const uuid = String(image._uuid); + const nativeExtension = String(image._native ?? ''); + const key = `${uuid}\0${nativeExtension}`; + const existing = this.textureSources.get(key); + if (existing) return existing.fileName; + const resolved = await lightFXBakeHost.resolveTextureSource({ uuid, nativeExtension }); + if (!resolved) return ''; + this.textureSources.set(key, { uuid, nativeExtension, fileName: resolved.fileName }); + return resolved.fileName; } private exportLight(light: Light, hdr: boolean): LightFXLight | null { diff --git a/src/core/scene/scene-process/service/baking/lightfx/format.ts b/src/core/scene/scene-process/service/baking/lightfx/format.ts index 6c3536472..88faf981b 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/format.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/format.ts @@ -1,5 +1,5 @@ import { LightFXBuffer } from './buffer'; -import { LIGHTFX_FILE_VERSION, LIGHTFX_OUTPUT_VERSIONS, LightFXChunk, LightFXResult, LightFXWorld } from './types'; +import { LIGHTFX_FILE_VERSION, LightFXChunk, LightFXWorld } from './types'; export function encodeLightFXInput(world: LightFXWorld): Uint8Array { const b = new LightFXBuffer(); const s = world.settings; @@ -21,16 +21,3 @@ export function encodeLightFXInput(world: LightFXWorld): Uint8Array { for (const p of world.probes) { b.writeInt32(LightFXChunk.LIGHT_PROBE); b.writeFloats(p.position); b.writeFloats(p.normal); } b.writeInt32(LightFXChunk.EOF); return b.toUint8Array(); } - -export function decodeLightFXOutput(input: Uint8Array): LightFXResult { - const b = new LightFXBuffer(input); const result: LightFXResult = { version: b.readInt32(), meshes: [], terrains: [], probes: [] }; - if (!LIGHTFX_OUTPUT_VERSIONS.has(result.version)) throw new Error(`Unsupported LightFX output version: 0x${result.version.toString(16)}.`); - while (b.remaining > 0) { - const chunk = b.readInt32(); if (chunk === LightFXChunk.EOF) return result; - if (chunk === LightFXChunk.TERRAIN) { const id = b.readInt32(); const count = b.readCount('terrain'); for (let i = 0; i < count; i++) result.terrains.push({ id, blockId: b.readInt32(), index: b.readInt32(), offset: b.readFloats(2), scale: b.readFloats(2) }); } - else if (chunk === LightFXChunk.MESH) { const count = b.readCount('mesh'); for (let i = 0; i < count; i++) result.meshes.push({ id: b.readInt32(), index: b.readInt32(), offset: b.readFloats(2), scale: b.readFloats(2) }); } - else if (chunk === LightFXChunk.LIGHT_PROBE) { const count = b.readCount('light probe'); for (let i = 0; i < count; i++) result.probes.push({ position: b.readFloats(3), normal: b.readFloats(3), coefficients: b.readFloats(b.readCount('coefficient')) }); } - else throw new Error(`Unknown LightFX output chunk: ${chunk}.`); - } - throw new Error('LightFX output has no EOF chunk.'); -} diff --git a/src/core/scene/scene-process/service/baking/lightfx/host.ts b/src/core/scene/scene-process/service/baking/lightfx/host.ts new file mode 100644 index 000000000..f01d790c8 --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/host.ts @@ -0,0 +1,25 @@ +import type { + IAppendLightFXInputOptions, + IBeginLightFXBakeOptions, + IBeginLightFXBakeResult, + ILightFXBakeHostService, + ILightFXOperationOptions, + IRemoveLightmapAssetsOptions, + IResolveLightFXTextureSourceOptions, + IResolvedLightFXTextureSource, + IRunLightFXBakeOptions, + IRunLightFXBakeResult, +} from '../../../../common/lightfx-host'; +import { Rpc } from '../../../rpc'; + +/** JSON-only bridge from either a child scene process or a browser scene Webview to the Node host. */ +export const lightFXBakeHost: ILightFXBakeHostService = { + resolveTextureSource: (options: IResolveLightFXTextureSourceOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'resolveTextureSource', [options]), + begin: (options: IBeginLightFXBakeOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'begin', [options]), + appendInput: (options: IAppendLightFXInputOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'appendInput', [options]), + run: (options: IRunLightFXBakeOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'run', [options]), + commit: (options: ILightFXOperationOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'commit', [options]), + rollback: (options: ILightFXOperationOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'rollback', [options]), + cancel: (): Promise<{ cancelled: boolean; target: 'light-probe' | 'lightmap' | null }> => Rpc.getInstance().request('lightFXBakeHost', 'cancel'), + removeLightmapAssets: (options: IRemoveLightmapAssetsOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'removeLightmapAssets', [options]), +}; diff --git a/src/core/scene/scene-process/service/baking/lightfx/process.ts b/src/core/scene/scene-process/service/baking/lightfx/process.ts deleted file mode 100644 index a9d7c52eb..000000000 --- a/src/core/scene/scene-process/service/baking/lightfx/process.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { ChildProcess, spawn } from 'child_process'; -import { createServer, Server as HttpServer } from 'http'; -import { existsSync } from 'fs'; -import { join } from 'path'; -import { GlobalPaths } from '../../../../../../global'; - -// LightFX embeds a Socket.IO 2.x client which cannot connect to the project's -// Socket.IO 4.x server even with Engine.IO 3 compatibility enabled. -const createLegacySocketServer = require('socket.io-v2') as (server: HttpServer, options: object) => any; - -export interface LightFXProcessOptions { - cwd: string; - timeoutMs: number; - signal?: AbortSignal; - onLog?: (message: string) => void; - onProgress?: (progress: unknown) => void; -} - -export class LightFXProcess { - private child: ChildProcess | null = null; - private http: HttpServer | null = null; - private io: any = null; - private settled = false; - - async run(options: LightFXProcessOptions): Promise { - if (this.child || this.io) throw new Error('LightFX process is already running.'); - const executable = join(GlobalPaths.staticDir, 'tools', 'lightmap-tools', process.platform === 'win32' ? 'LightFX.exe' : 'LightFX'); - if (!existsSync(executable)) throw new Error(`LightFX executable was not found: ${executable}`); - this.settled = false; - await new Promise((resolve, reject) => { - let timer: NodeJS.Timeout; - const finish = async (error?: unknown): Promise => { - if (this.settled) return; - this.settled = true; - clearTimeout(timer); - options.signal?.removeEventListener('abort', abort); - await this.close(); - if (error) reject(error instanceof Error ? error : new Error(String(error))); else resolve(); - }; - const fail = (error: unknown): void => { void finish(error); }; - const succeed = (): void => { void finish(); }; - timer = setTimeout(() => fail(new Error('LightFX bake timed out.')), options.timeoutMs); - const abort = (): void => fail(new Error('LightFX bake was cancelled.')); - options.signal?.addEventListener('abort', abort, { once: true }); - void (async () => { try { - this.http = createServer(); - this.io = createLegacySocketServer(this.http, { serveClient: false, transports: ['websocket', 'polling'] }); - this.io.on('connection', (socket: any) => { - socket.once('Login', () => socket.emit('Start')); - socket.on('Log', (data: unknown) => options.onLog?.(String(data))); - socket.on('Progress', (data: unknown) => options.onProgress?.(data)); - socket.once('Finished', () => { socket.emit('Stop'); succeed(); }); - }); - await new Promise((ready, listenReject) => { - this.http!.once('error', listenReject); - this.http!.listen(0, '127.0.0.1', () => ready()); - }); - const address = this.http.address(); - if (!address || typeof address === 'string') throw new Error('LightFX server did not allocate a TCP port.'); - this.child = spawn(executable, [`http://127.0.0.1:${address.port}`], { cwd: options.cwd, windowsHide: true }); - this.child.once('error', fail); - this.child.once('exit', (code, signal) => { if (!this.settled) fail(new Error(`LightFX exited before completion (code=${code}, signal=${signal}).`)); }); - } catch (error) { fail(error); } })(); - }); - } - - async cancel(): Promise { const running = Boolean(this.child || this.io); this.settled = true; await this.close(); return running; } - private async close(): Promise { - if (this.child) { this.child.kill(); this.child = null; } - if (this.io) { await new Promise((resolve) => this.io!.close(() => resolve())); this.io = null; } - if (this.http) { if (this.http.listening) await new Promise((resolve) => this.http!.close(() => resolve())); this.http = null; } - } -} diff --git a/src/core/scene/scene-process/service/baking/lightfx/types.ts b/src/core/scene/scene-process/service/baking/lightfx/types.ts index 9f60e0178..287e23496 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/types.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/types.ts @@ -1,4 +1,6 @@ -export type LightFXBakeTarget = 'light-probe' | 'lightmap'; +import type { ILightFXResult } from '../../../../common/lightfx-host'; + +export type { LightFXBakeTarget } from '../../../../common/lightfx-host'; export interface LightFXSettings { msaa: number; size: number; gamma: number; highp: boolean; skyRadiance: number[]; @@ -15,11 +17,7 @@ export interface LightFXTerrain { position: number[]; tileSize: number; blockCou export interface LightFXLight { type: number; position: number[]; direction: number[]; color: number[]; size: number; range: number; attenuationFalloff: number; spotInner: number; spotOuter: number; spotFalloff: number; directScale: number; indirectScale: number; giEnabled: boolean; castShadow: boolean; shadowMask: number } export interface LightFXProbe { position: number[]; normal: number[] } export interface LightFXWorld { name: string; settings: LightFXSettings; meshes: LightFXMesh[]; terrains: LightFXTerrain[]; lights: LightFXLight[]; probes: LightFXProbe[]; textures: string[] } -export interface LightFXMeshResult { id: number; index: number; offset: number[]; scale: number[] } -export interface LightFXTerrainResult extends LightFXMeshResult { blockId: number } -export interface LightFXProbeResult { position: number[]; normal: number[]; coefficients: number[] } -export interface LightFXResult { version: number; meshes: LightFXMeshResult[]; terrains: LightFXTerrainResult[]; probes: LightFXProbeResult[] } +export type LightFXResult = ILightFXResult; export const LIGHTFX_FILE_VERSION = 0x3730; -export const LIGHTFX_OUTPUT_VERSIONS = new Set([0x2000, 0x2002, 0x2003, LIGHTFX_FILE_VERSION]); export const enum LightFXChunk { EOF = 0, TERRAIN = 1, MESH = 2, LIGHT = 3, LIGHT_PROBE = 4 } diff --git a/src/core/scene/scene-process/service/light-probe-bake.ts b/src/core/scene/scene-process/service/light-probe-bake.ts index ea2bf9960..0dd95b97a 100644 --- a/src/core/scene/scene-process/service/light-probe-bake.ts +++ b/src/core/scene/scene-process/service/light-probe-bake.ts @@ -1,5 +1,4 @@ import { director, Scene, SH, Vec3 } from 'cc'; -import { remove } from 'fs-extra'; import type { ILightFXBakeEvents, ILightFXCancelResult, @@ -50,6 +49,7 @@ export class LightProbeBakeService extends BaseService imple await Service.Engine.repaintInEditMode(); if (options.saveScene !== false) await Service.Editor.save({}); await Service.Undo.endRecording(undo); + await lightFXCoordinator.commit(output.operationId); } catch (error) { Service.Undo.cancelRecording(undo); throw error; @@ -58,13 +58,12 @@ export class LightProbeBakeService extends BaseService imple this.broadcast('lightfx:bake-end', 'light-probe'); return { sceneUrl, probeCount: probes.length, giScale, giSamples, bounces, durationMs: Date.now() - started }; } catch (error) { + if (output) await lightFXCoordinator.rollback(output.operationId).catch(() => undefined); this.restore(probes, previous); info.onProbeBakeFinished(); await Service.Engine.repaintInEditMode(); this.broadcast('lightfx:bake-end', 'light-probe', this.errorMessage(error)); throw error; - } finally { - if (output) await remove(output.workspace).catch(() => undefined); } } diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index a9662077a..71efe9b4c 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -1,16 +1,14 @@ -import { assetManager, director, MeshRenderer, Scene, Terrain, Texture2D } from 'cc'; -import { copy, readdir, remove } from 'fs-extra'; -import { join } from 'path'; +import { director, MeshRenderer, Scene, Terrain, Texture2D } from 'cc'; import type { ILightFXBakeEvents, ILightFXCancelResult, ILightmapBakeOptions, ILightmapBakeResult, ILightmapBakeService, } from '../../common'; import { Rpc } from '../rpc'; -import { LightmapAssetTransaction } from './baking/lightfx/asset-transaction'; import { lightFXCoordinator } from './baking/lightfx/baker'; import type { LightFXBakeOutput } from './baking/lightfx/baker'; import { createDefaultLightFXSettings } from './baking/lightfx/settings'; import { BaseService, register, Service } from './core'; +import { loadPreviewAsset } from './preview/asset-reload'; interface LightmapBinding { target: any; @@ -45,8 +43,6 @@ export class LightmapBakeService extends BaseService impleme const timeoutMs = options.timeoutMs ?? 600_000; let output: LightFXBakeOutput | undefined; - let assets: LightmapAssetTransaction | undefined; - let refreshUrl: string | undefined; this.broadcast('lightfx:bake-start', 'lightmap'); try { output = await lightFXCoordinator.bake(scene, 'lightmap', settings, timeoutMs); @@ -54,14 +50,7 @@ export class LightmapBakeService extends BaseService impleme throw new Error('No bakeable meshes or terrains were found.'); } - const assetRoot = await Rpc.getInstance().request('assetManager', 'queryPath', ['db://assets']) as string; - const targetDir = join(assetRoot, scene.name, 'lightmap'); const targetUrl = `db://assets/${scene.name}/lightmap`; - refreshUrl = `db://assets/${scene.name}`; - assets = new LightmapAssetTransaction(targetDir, output.workspace); - await assets.prepare(); - - const textureUrls = await this.importOutputTextures(output, assets, targetDir, targetUrl); const textures = await this.loadOutputTextures(output, targetUrl, timeoutMs); const previousBindings = this.snapshotBindings(output); const previousHighp = (scene.globals as any).bakedWithHighpLightmap; @@ -74,6 +63,7 @@ export class LightmapBakeService extends BaseService impleme await Service.Engine.repaintInEditMode(); if (options.saveScene !== false) await Service.Editor.save({}); await Service.Undo.endRecording(undo); + await lightFXCoordinator.commit(output.operationId); } catch (error) { this.restoreBindings(previousBindings); (scene.globals as any).bakedWithHighpLightmap = previousHighp; @@ -85,24 +75,17 @@ export class LightmapBakeService extends BaseService impleme this.broadcast('lightfx:bake-end', 'lightmap'); return { sceneUrl, - textureUrls, + textureUrls: output.textureUrls, meshCount: output.result.meshes.length, terrainCount: output.result.terrains.length, durationMs: Date.now() - started, }; } catch (error) { - if (assets) { - try { - await assets.rollback(); - if (refreshUrl) await Rpc.getInstance().request('assetManager', 'refreshAsset', [refreshUrl]); - } catch (rollbackError) { - console.error('[LightFX] Failed to roll back lightmap assets:', rollbackError); - } - } + if (output) await lightFXCoordinator.rollback(output.operationId).catch((rollbackError) => { + console.error('[LightFX] Failed to roll back lightmap assets:', rollbackError); + }); this.broadcast('lightfx:bake-end', 'lightmap', this.errorMessage(error)); throw error; - } finally { - if (output) await remove(output.workspace).catch(() => undefined); } } @@ -131,9 +114,7 @@ export class LightmapBakeService extends BaseService impleme } if (options.deleteAssets) { - const root = await Rpc.getInstance().request('assetManager', 'queryPath', ['db://assets']) as string; - await remove(join(root, scene.name, 'lightmap')); - await Rpc.getInstance().request('assetManager', 'refreshAsset', [`db://assets/${scene.name}`]); + await lightFXCoordinator.removeLightmapAssets(scene.name); } return { clearedCount: bindings.length }; } @@ -149,22 +130,6 @@ export class LightmapBakeService extends BaseService impleme return sceneUrl; } - private async importOutputTextures( - output: LightFXBakeOutput, - assets: LightmapAssetTransaction, - targetDir: string, - targetUrl: string, - ): Promise { - const files = (await readdir(output.outputDir)).filter((file) => file.toLowerCase().endsWith('.png')); - if (!files.length) throw new Error('LightFX did not produce any lightmap textures.'); - for (const file of files) { - await copy(join(output.outputDir, file), join(targetDir, file), { overwrite: true }); - await assets.preserveMeta(file); - } - await Rpc.getInstance().request('assetManager', 'refreshAsset', [targetUrl]); - return files.map((file) => `${targetUrl}/${file}`); - } - private async loadOutputTextures( output: LightFXBakeOutput, targetUrl: string, @@ -172,25 +137,26 @@ export class LightmapBakeService extends BaseService impleme ): Promise> { const textures = new Map(); for (const item of output.result.meshes) { - await this.loadIndexedTexture(textures, 'mesh', item.index, targetUrl, timeoutMs); + await this.loadIndexedTexture(textures, 'mesh', item.index, output.textureUrls, targetUrl, timeoutMs); } for (const item of output.result.terrains) { - await this.loadIndexedTexture(textures, 'terrain', item.index, targetUrl, timeoutMs); + await this.loadIndexedTexture(textures, 'terrain', item.index, output.textureUrls, targetUrl, timeoutMs); } return textures; } private async loadIndexedTexture( textures: Map, kind: 'mesh' | 'terrain', index: number, - targetUrl: string, timeoutMs: number, + textureUrls: readonly string[], targetUrl: string, timeoutMs: number, ): Promise { const key = `${kind}:${index}`; if (textures.has(key)) return; const prefix = kind === 'mesh' ? 'Mesh' : 'Terrain'; const file = `LFX_${prefix}_${String(index).padStart(4, '0')}.png`; - const uuid = await this.waitForAsset(`${targetUrl}/${file}`, Math.min(timeoutMs, 60_000)); - await this.disableAlphaFix(uuid); - textures.set(key, await this.loadTexture(`${uuid}@6c48a`)); + const textureUrl = textureUrls.find((url) => url === `${targetUrl}/${file}` || url.endsWith(`/${file}`)); + if (!textureUrl) throw new Error(`LightFX did not produce the expected lightmap texture: ${file}`); + const uuid = await this.waitForAsset(textureUrl, Math.min(timeoutMs, 60_000)); + textures.set(key, await this.loadTexture(`${uuid}@6c48a`, timeoutMs)); } private applyBakeResult(output: LightFXBakeOutput, textures: Map): void { @@ -287,19 +253,10 @@ export class LightmapBakeService extends BaseService impleme throw new Error(`Lightmap texture import timed out: ${url}`); } - private async disableAlphaFix(uuid: string): Promise { - const rpc = Rpc.getInstance(); - const meta = await rpc.request('assetManager', 'queryAssetMeta', [uuid]) as any; - if (meta?.userData?.fixAlphaTransparencyArtifacts === false) return; - if (!meta) throw new Error(`Lightmap texture metadata is unavailable: ${uuid}`); - meta.userData ??= {}; - meta.userData.fixAlphaTransparencyArtifacts = false; - await rpc.request('assetManager', 'saveAssetMeta', [uuid, meta]); - } - - private loadTexture(uuid: string): Promise { - return new Promise((resolve, reject) => { - assetManager.loadAny(uuid, (error, asset: Texture2D) => error ? reject(error) : resolve(asset)); + private loadTexture(uuid: string, timeoutMs: number): Promise { + return loadPreviewAsset(uuid, 'lightmap texture', { + reloadAsset: true, + timeoutMs, }); } diff --git a/src/core/scene/test/lightfx-asset-transaction.test.ts b/src/core/scene/test/lightfx-asset-transaction.test.ts index d7ae2d18a..cea19d779 100644 --- a/src/core/scene/test/lightfx-asset-transaction.test.ts +++ b/src/core/scene/test/lightfx-asset-transaction.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, outputFile, pathExists, readFile, remove } from 'fs-extra'; import { join } from 'path'; import { tmpdir } from 'os'; -import { LightmapAssetTransaction } from '../scene-process/service/baking/lightfx/asset-transaction'; +import { LightmapAssetTransaction } from '../main-process/lightfx/asset-transaction'; describe('LightmapAssetTransaction', () => { let root: string; @@ -32,4 +32,19 @@ describe('LightmapAssetTransaction', () => { await transaction.rollback(); expect(await pathExists(target)).toBe(false); }); + + it('keeps rollback retryable when restoring the backup fails', async () => { + const target = join(root, 'assets', 'Scene', 'lightmap'); + await outputFile(join(target, 'old.png'), 'old'); + const workspace = join(root, 'workspace'); + const backup = join(workspace, 'lightmap-asset-backup'); + const transaction = new LightmapAssetTransaction(target, workspace); + await transaction.prepare(); + await remove(backup); + + await expect(transaction.rollback()).rejects.toThrow(); + await outputFile(join(backup, 'old.png'), 'old'); + await expect(transaction.rollback()).resolves.toBeUndefined(); + await expect(readFile(join(target, 'old.png'), 'utf8')).resolves.toBe('old'); + }); }); diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts new file mode 100644 index 000000000..dedc1463e --- /dev/null +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -0,0 +1,302 @@ +import { mkdtemp, outputFile, pathExists, readFile, readdir, remove } from 'fs-extra'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const mockAssetManager = { + queryPath: jest.fn(), + refreshAsset: jest.fn(), + queryUUID: jest.fn(), + queryAssetMeta: jest.fn(), + saveAssetMeta: jest.fn(), +}; +const mockRunnerRun = jest.fn(); +const mockRunnerCancel = jest.fn(); +const mockDecodedResult = { version: 1, meshes: [], terrains: [], probes: [] }; + +jest.mock('../../assets', () => ({ assetManager: mockAssetManager })); +jest.mock('../main-process/lightfx/process', () => ({ + LightFXProcess: jest.fn().mockImplementation(() => ({ + run: mockRunnerRun, + cancel: mockRunnerCancel, + })), +})); +jest.mock('../main-process/lightfx/asset-transaction', () => ({ + LightmapAssetTransaction: jest.fn(), +})); +jest.mock('../main-process/lightfx/output', () => ({ + decodeLightFXOutput: jest.fn(() => mockDecodedResult), +})); + +import { LightFXBakeHost } from '../main-process/lightfx-bake-host'; + +describe('LightFXBakeHost', () => { + let root: string; + let assetRoot: string; + let host: LightFXBakeHost; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'lightfx-host-')); + assetRoot = join(root, 'assets'); + mockAssetManager.queryPath.mockReset().mockImplementation((value: string) => ( + value === 'db://assets' ? assetRoot : null + )); + mockAssetManager.refreshAsset.mockReset().mockResolvedValue(undefined); + mockAssetManager.queryUUID.mockReset(); + mockAssetManager.queryAssetMeta.mockReset(); + mockAssetManager.saveAssetMeta.mockReset(); + mockRunnerRun.mockReset(); + mockRunnerCancel.mockReset().mockResolvedValue(undefined); + host = new LightFXBakeHost(); + }); + + afterEach(async () => { + await host.dispose(); + await remove(root); + }); + + async function finishLightProbe(): Promise { + mockRunnerRun.mockImplementationOnce(async ({ cwd }: { cwd: string }) => { + await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); + }); + const { operationId } = await host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + }); + await host.appendInput({ operationId, chunkBase64: Buffer.from('input').toString('base64') }); + await expect(host.run({ operationId })).resolves.toEqual({ result: mockDecodedResult, textureUrls: [] }); + return operationId; + } + + it('accepts chunked input, reserves one operation, and rolls it back idempotently', async () => { + const { operationId } = await host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + }); + + await expect(host.begin({ + target: 'lightmap', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + })).rejects.toThrow('A light-probe LightFX bake is already in progress.'); + + await host.appendInput({ operationId, chunkBase64: Buffer.from('first').toString('base64') }); + await host.appendInput({ operationId, chunkBase64: Buffer.from('-second').toString('base64') }); + + const workspaces = await readdir(join(root, 'temp', 'lightfx-bake')); + expect(workspaces).toHaveLength(1); + await expect(readFile(join(root, 'temp', 'lightfx-bake', workspaces[0], 'tmp', 'lfx.in'), 'utf8')) + .resolves.toBe('first-second'); + + await host.rollback({ operationId }); + await expect(host.rollback({ operationId })).resolves.toBeUndefined(); + expect(mockRunnerCancel).toHaveBeenCalledTimes(1); + await expect(pathExists(join(root, 'temp', 'lightfx-bake', workspaces[0]))) + .resolves.toBe(false); + + await expect(host.begin({ + target: 'lightmap', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + })).resolves.toEqual({ operationId: expect.any(String) }); + }); + + it('rejects invalid requests without leaving a reserved operation behind', async () => { + await expect(host.begin({ + target: 'light-probe', + sceneName: '../LightProbe', + textureSources: [], + timeoutMs: 120_000, + })).rejects.toThrow('Invalid LightFX scene name.'); + await expect(host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 999, + })).rejects.toThrow('LightFX timeout must be an integer between 1000 and 3600000 milliseconds.'); + + const { operationId } = await host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + }); + await expect(host.appendInput({ operationId, chunkBase64: 'not-base64' })) + .rejects.toThrow('Invalid base64 LightFX input chunk.'); + await expect(host.appendInput({ operationId: 'missing', chunkBase64: '' })) + .rejects.toThrow('Unknown LightFX operation: missing'); + }); + + it('returns a stable no-op result when there is no operation to cancel', async () => { + await expect(host.cancel()).resolves.toEqual({ cancelled: false, target: null }); + expect(mockRunnerCancel).not.toHaveBeenCalled(); + }); + + it('reports cancellation instead of an unknown operation when upload continues after cancel', async () => { + const { operationId } = await host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + }); + + await expect(host.cancel()).resolves.toEqual({ cancelled: true, target: 'light-probe' }); + await expect(host.appendInput({ + operationId, + chunkBase64: Buffer.from('late chunk').toString('base64'), + })).rejects.toThrow('LightFX bake was cancelled.'); + await expect(host.run({ operationId })).rejects.toThrow('LightFX bake was cancelled.'); + }); + + it('waits for an accepted input write before removing the operation workspace', async () => { + const { operationId } = await host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + }); + const operation = (host as any).operation; + let finishWrite!: () => void; + operation.inputWritePromise = new Promise((resolve) => { + finishWrite = resolve; + }); + + const rollingBack = host.rollback({ operationId }); + await Promise.resolve(); + await expect(pathExists(operation.workspace)).resolves.toBe(true); + + finishWrite(); + await expect(rollingBack).resolves.toBeUndefined(); + await expect(pathExists(operation.workspace)).resolves.toBe(false); + }); + + it('makes commit idempotent but rejects commit after rollback', async () => { + const committedId = await finishLightProbe(); + await expect(Promise.all([ + host.commit({ operationId: committedId }), + host.commit({ operationId: committedId }), + ])).resolves.toEqual([undefined, undefined]); + await expect(host.commit({ operationId: committedId })).resolves.toBeUndefined(); + + const { operationId: rolledBackId } = await host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + }); + await host.rollback({ operationId: rolledBackId }); + await expect(host.commit({ operationId: rolledBackId })) + .rejects.toThrow('LightFX bake was rolled-back and cannot be committed.'); + }); + + it('lets cancel win atomically after run and prevents a stale scene result from committing', async () => { + const operationId = await finishLightProbe(); + const cancelling = host.cancel(); + + await expect(host.commit({ operationId })) + .rejects.toThrow('LightFX bake was cancelled and cannot be committed.'); + await expect(cancelling).resolves.toEqual({ cancelled: true, target: 'light-probe' }); + await expect(host.commit({ operationId })) + .rejects.toThrow('LightFX bake was cancelled and cannot be committed.'); + }); + + it('lets commit win atomically over a concurrent cancel request', async () => { + const operationId = await finishLightProbe(); + const committing = host.commit({ operationId }); + + await expect(host.cancel()).resolves.toEqual({ cancelled: false, target: null }); + await expect(committing).resolves.toBeUndefined(); + await expect(host.commit({ operationId })).resolves.toBeUndefined(); + }); + + it('preserves a rollback backup and the active operation when restoration fails', async () => { + const { operationId } = await host.begin({ + target: 'lightmap', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + }); + const operation = (host as any).operation; + const rollbackAssets = jest.fn() + .mockRejectedValueOnce(new Error('restore failed')) + .mockResolvedValueOnce(undefined); + operation.assets = { rollback: rollbackAssets }; + + await expect(host.rollback({ operationId })).rejects.toThrow('restore failed'); + await expect(pathExists(operation.workspace)).resolves.toBe(true); + expect((host as any).completedOperations.has(operationId)).toBe(false); + expect((host as any).operation).toBe(operation); + + await expect(host.rollback({ operationId })).resolves.toBeUndefined(); + expect(rollbackAssets).toHaveBeenCalledTimes(2); + await expect(pathExists(operation.workspace)).resolves.toBe(false); + await expect(host.commit({ operationId })) + .rejects.toThrow('LightFX bake was rolled-back and cannot be committed.'); + }); + + it('marks an awaiting commit as expired before asynchronous cleanup starts', async () => { + jest.useFakeTimers(); + try { + mockRunnerRun.mockImplementationOnce(async ({ cwd }: { cwd: string }) => { + await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); + }); + const { operationId } = await host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 1_000, + }); + await host.appendInput({ operationId, chunkBase64: Buffer.from('input').toString('base64') }); + await host.run({ operationId }); + + jest.advanceTimersByTime(1_000); + await expect(host.commit({ operationId })) + .rejects.toThrow('LightFX bake was expired and cannot be committed.'); + await Promise.resolve(); + await Promise.resolve(); + } finally { + jest.useRealTimers(); + } + }); + + it('lets run serialize cleanup when expiry interrupts an active bake', async () => { + jest.useFakeTimers(); + try { + let rejectRun!: (error: Error) => void; + mockRunnerRun.mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectRun = reject; + })); + const { operationId } = await host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 1_000, + }); + const operation = (host as any).operation; + await host.appendInput({ operationId, chunkBase64: Buffer.from('input').toString('base64') }); + const runResult = expect(host.run({ operationId })).rejects.toThrow('LightFX bake timed out.'); + await Promise.resolve(); + expect(mockRunnerRun).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(1_000); + await Promise.resolve(); + await Promise.resolve(); + expect(mockRunnerCancel).toHaveBeenCalledTimes(1); + expect(operation.cleanupPromise).toBeNull(); + await expect(pathExists(operation.workspace)).resolves.toBe(true); + + rejectRun(new Error('process stopped')); + await runResult; + await expect(pathExists(operation.workspace)).resolves.toBe(false); + await expect(host.commit({ operationId })) + .rejects.toThrow('LightFX bake was expired and cannot be committed.'); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/src/core/scene/test/lightfx-bake-renderer.test.ts b/src/core/scene/test/lightfx-bake-renderer.test.ts new file mode 100644 index 000000000..58252a05f --- /dev/null +++ b/src/core/scene/test/lightfx-bake-renderer.test.ts @@ -0,0 +1,95 @@ +const mockSocketService: { io?: any } = {}; + +jest.mock('../../../server/socket', () => ({ + SCENE_RENDERER_ROOM: 'scene-renderer', + socketService: mockSocketService, +})); + +import { lightFXBakeRenderer } from '../main-process/lightfx-bake-renderer'; + +interface FakeSocketOptions { + id: string; + sceneUrl?: string; + visible?: boolean; + result?: unknown; +} + +function createSocket(options: FakeSocketOptions) { + const emit = jest.fn((_event, request, callback) => callback(null, { + result: options.result ?? { sceneUrl: options.sceneUrl }, + sceneUrl: options.sceneUrl, + })); + return { + id: options.id, + data: { + sceneUrl: options.sceneUrl, + sceneRendererVisible: options.visible, + }, + timeout: jest.fn(() => ({ emit })), + emit, + }; +} + +function useSockets(sockets: ReturnType[]) { + mockSocketService.io = { + in: jest.fn(() => ({ fetchSockets: jest.fn(async () => sockets) })), + }; +} + +describe('LightFX active scene renderer routing', () => { + afterEach(() => { + mockSocketService.io = undefined; + jest.clearAllMocks(); + }); + + it('routes a bake to the visible loaded renderer instead of a hidden preload renderer', async () => { + const hidden = createSocket({ id: 'hidden', sceneUrl: '', visible: false }); + const visible = createSocket({ + id: 'visible', + sceneUrl: 'db://assets/LightProbe.scene', + visible: true, + result: { sceneUrl: 'db://assets/LightProbe.scene', probeCount: 8 }, + }); + useSockets([hidden, visible]); + const fallback = jest.fn(); + + await expect(lightFXBakeRenderer.invoke( + 'LightProbeBake', 'bake', [{}], 600_000, fallback, true, + )).resolves.toEqual({ sceneUrl: 'db://assets/LightProbe.scene', probeCount: 8 }); + + expect(fallback).not.toHaveBeenCalled(); + expect(hidden.timeout).not.toHaveBeenCalled(); + expect(visible.emit).toHaveBeenCalledWith( + 'scene:invoke-lightfx', + expect.objectContaining({ + sceneUrl: 'db://assets/LightProbe.scene', + module: 'LightProbeBake', + method: 'bake', + }), + expect.any(Function), + ); + }); + + it('falls back to the Scene Worker when no WebGL scene renderer is connected', async () => { + useSockets([]); + const fallback = jest.fn(async () => ({ probeCount: 4 })); + + await expect(lightFXBakeRenderer.invoke( + 'LightProbeBake', 'bake', [{}], 600_000, fallback, true, + )).resolves.toEqual({ probeCount: 4 }); + expect(fallback).toHaveBeenCalledTimes(1); + }); + + it('does not silently bake in the Scene Worker when the visible renderer has no scene', async () => { + useSockets([ + createSocket({ id: 'visible', sceneUrl: '', visible: true }), + createSocket({ id: 'hidden', sceneUrl: 'db://assets/Other.scene', visible: false }), + ]); + const fallback = jest.fn(); + + await expect(lightFXBakeRenderer.invoke( + 'LightProbeBake', 'bake', [{}], 600_000, fallback, true, + )).rejects.toThrow('visible scene renderer has not finished loading'); + expect(fallback).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/scene/test/lightfx-format.test.ts b/src/core/scene/test/lightfx-format.test.ts index 480a9b6ea..1e11e476c 100644 --- a/src/core/scene/test/lightfx-format.test.ts +++ b/src/core/scene/test/lightfx-format.test.ts @@ -1,7 +1,8 @@ import { LightFXBuffer } from '../scene-process/service/baking/lightfx/buffer'; -import { decodeLightFXOutput, encodeLightFXInput } from '../scene-process/service/baking/lightfx/format'; +import { encodeLightFXInput } from '../scene-process/service/baking/lightfx/format'; import { LIGHTFX_FILE_VERSION, LightFXChunk, LightFXWorld } from '../scene-process/service/baking/lightfx/types'; import { createDefaultLightFXSettings } from '../scene-process/service/baking/lightfx/settings'; +import { decodeLightFXOutput } from '../main-process/lightfx/output'; describe('LightFX binary format', () => { it('encodes both bake target flags and scene chunks', () => { diff --git a/src/server/socket.ts b/src/server/socket.ts index ca38fb715..f520ceca9 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; @@ -19,6 +21,24 @@ export class SocketService { }); this.io.on('connection', (socket: any) => { console.log(`socket ${socket.id} connected`); + socket.on('scene-renderer:register', (data?: { sceneUrl?: string; visible?: boolean }) => { + socket.join(SCENE_RENDERER_ROOM); + socket.data.sceneRenderer = true; + socket.data.sceneUrl = data?.sceneUrl || ''; + if (typeof data?.visible === 'boolean') { + socket.data.sceneRendererVisible = data.visible; + } + }); + socket.on('scene-renderer:scene', (data?: { sceneUrl?: string }) => { + if (socket.data.sceneRenderer) { + socket.data.sceneUrl = data?.sceneUrl || ''; + } + }); + socket.on('scene-renderer:visibility', (data?: { visible?: boolean }) => { + if (socket.data.sceneRenderer && typeof data?.visible === 'boolean') { + socket.data.sceneRendererVisible = data.visible; + } + }); middlewareService.middlewareSocket.forEach((middleware) => { middleware.connection(socket); }); diff --git a/workflow/build-scene-bundle.js b/workflow/build-scene-bundle.js index d9e4eedf7..fccb9f246 100644 --- a/workflow/build-scene-bundle.js +++ b/workflow/build-scene-bundle.js @@ -402,7 +402,7 @@ async function buildSceneBundle() { }); const bundleOutputFile = path.join(workspaceDir, 'static', 'web', 'scene-bundle.js'); - await bundle.write({ + const sceneBundleOutput = await bundle.write({ file: bundleOutputFile, format: 'system', sourcemap: true, @@ -436,6 +436,14 @@ async function buildSceneBundle() { ` }); + const unexpectedImports = sceneBundleOutput.output + .filter((item) => item.type === 'chunk') + .flatMap((item) => item.imports) + .filter((id) => id !== 'cc'); + if (unexpectedImports.length) { + throw new Error(`Scene Web bundle contains unsupported external imports: ${[...new Set(unexpectedImports)].join(', ')}`); + } + console.log('[Build] Successfully bundled to', bundleOutputFile); } From d0a56fc3fcce427ea4e568bd4602b506df5f8a79 Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Tue, 8 Sep 2026 10:34:42 +0800 Subject: [PATCH 05/64] feat(scene): query lightmap bake information --- docs/dev/scene/lightfx-bake.md | 39 ++++++++ .../__snapshots__/dts-snapshot.test.ts.snap | 19 ++++ src/api/scene/lightfx-bake-schema.ts | 21 ++++ src/api/scene/lightfx-bake.ts | 13 ++- src/core/scene/common/lightfx-bake.ts | 15 ++- src/core/scene/common/lightfx-host.ts | 19 ++++ .../scene/main-process/lightfx-bake-host.ts | 45 +++++++++ .../main-process/lightfx-bake-renderer.ts | 2 +- .../main-process/proxy/lightfx-bake-proxy.ts | 4 + .../scene/scene-process/engine-bootstrap.ts | 4 +- .../service/baking/lightfx/host.ts | 3 + .../scene-process/service/lightmap-bake.ts | 45 ++++++++- src/core/scene/test/lightfx-bake-host.test.ts | 29 ++++++ .../scene/test/lightfx-bake-renderer.test.ts | 19 ++++ .../scene/test/lightmap-bake-info.test.ts | 95 +++++++++++++++++++ tests/lightfx-bake-api.test.ts | 28 +++++- 16 files changed, 389 insertions(+), 11 deletions(-) create mode 100644 src/core/scene/test/lightmap-bake-info.test.ts diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 8df672847..5940f6fb9 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -149,6 +149,43 @@ MCP API 只负责参数校验和结果封装。场景运行时负责导出场景 } ``` +### 查询 Lightmap 烘焙信息 + +工具名:`scene-query-lightmap-bake-info` + +该只读工具不接收参数。它从当前活动场景中 MeshRenderer 和 Terrain 的实际 Lightmap 绑定反查资源,不依赖 Creator Lightmap 面板的私有 Profile: + +```json +{ + "result": { + "code": 200, + "data": { + "sceneUrl": "db://assets/LightProbe.scene", + "baked": true, + "meshCount": 1, + "terrainCount": 0, + "highp": false, + "stationaryMainLight": false, + "textures": [ + { + "uuid": "texture-asset-uuid", + "url": "db://assets/LightProbe/lightmap/LFX_Mesh_0000.png", + "filename": "LFX_Mesh_0000.png", + "size": 45650, + "createdAt": 1788782429000, + "modifiedAt": 1788782429000 + } + ], + "missingTextureUuids": [] + } + } +} +``` + +`size` 的单位为字节,时间字段为 Unix 毫秒时间戳。`meshCount` 和 `terrainCount` 是当前绑定 Lightmap 的组件数量;重复使用的贴图在 `textures` 中只返回一次。场景仍然存在贴图绑定但 Asset DB 或源文件缺失时,根资源 UUID 会列入 `missingTextureUuids`。 + +Pink 应在场景打开、烘焙完成和清理完成后调用该工具刷新面板。缩略图加载、RGBA 通道切换和时间格式化由 Pink 根据资源 URL/UUID 实现,CLI 不传输图片像素。 + ### 清理 Lightmap 工具名:`scene-clear-lightmap` @@ -232,6 +269,8 @@ CLI 烘焙并保存后,Creator 重新打开场景可以正常加载和显示 L Creator Lightmap 面板的“清除”操作依赖该面板自己保存的 `latestLightmapResultDir`。CLI 不写入 Creator 的私有面板状态,因此 Creator 面板可能无法清除 CLI 生成的 Lightmap。请使用 `scene-clear-lightmap` 清理 CLI 烘焙结果。CLI 不伪造 Creator Profile 状态,以避免耦合面板内部实现或误删资源。 +Pink 的烘焙信息面板应使用 `scene-query-lightmap-bake-info`,以当前场景真实绑定作为数据源,不需要兼容 Creator 的 `latestLightmapResultMap`。 + ## 运行时兼容性 随 Creator 提供的 LightFX 可执行程序使用 Socket.IO 2.x 协议,而 CLI 现有服务使用 Socket.IO 4.x。项目通过 npm alias `socket.io-v2` 提供仅供 LightFX 本地进程桥接使用的 2.3.0 服务: 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 ca0e53137..1401b2ba0 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 @@ -6403,6 +6403,16 @@ export declare interface ILightFXCancelResult { cancelled: boolean; target: 'light-probe' | 'lightmap' | null; } +export declare interface ILightmapBakeInfo { + sceneUrl: string; + baked: boolean; + meshCount: number; + terrainCount: number; + highp: boolean; + stationaryMainLight: boolean; + textures: ILightmapTextureInfo[]; + missingTextureUuids: string[]; +} export declare interface ILightmapBakeOptions { msaa?: 1 | 2 | 4 | 8; resolution?: number; @@ -6428,6 +6438,7 @@ export declare interface ILightmapBakeResult { } export declare interface ILightmapBakeService extends IServiceEvents { bake(options: ILightmapBakeOptions): Promise; + queryBakeInfo(): Promise; clearBake(options?: { saveScene?: boolean; deleteAssets?: boolean; @@ -6436,6 +6447,14 @@ export declare interface ILightmapBakeService extends IServiceEvents { }>; cancel(): Promise; } +export declare interface ILightmapTextureInfo { + uuid: string; + url: string; + filename: string; + size: number; + createdAt: number; + modifiedAt: number; +} export declare interface ILightProbeBakeOptions { giScale?: number; giSamples?: number; diff --git a/src/api/scene/lightfx-bake-schema.ts b/src/api/scene/lightfx-bake-schema.ts index 47429488e..be76e0dc3 100644 --- a/src/api/scene/lightfx-bake-schema.ts +++ b/src/api/scene/lightfx-bake-schema.ts @@ -37,6 +37,26 @@ export const SchemaLightmapBakeResult = z.object({ terrainCount: z.number().int().nonnegative(), durationMs: z.number().nonnegative(), }); +export const SchemaLightmapTextureInfo = z.object({ + uuid: z.string(), + url: z.string(), + filename: z.string(), + size: z.number().int().nonnegative(), + createdAt: z.number().finite().nonnegative(), + modifiedAt: z.number().finite().nonnegative(), +}); + +export const SchemaLightmapBakeInfo = z.object({ + sceneUrl: z.string(), + baked: z.boolean(), + meshCount: z.number().int().nonnegative(), + terrainCount: z.number().int().nonnegative(), + highp: z.boolean(), + stationaryMainLight: z.boolean(), + textures: z.array(SchemaLightmapTextureInfo), + missingTextureUuids: z.array(z.string()), +}); + export const SchemaLightFXCancelResult = z.object({ cancelled: z.boolean(), target: z.enum(['light-probe', 'lightmap']).nullable(), }); @@ -49,3 +69,4 @@ export type TLightProbeBakeOptions = z.infer export type TLightProbeBakeResult = z.infer; export type TLightmapBakeOptions = z.infer; export type TLightmapBakeResult = z.infer; +export type TLightmapBakeInfo = z.infer; diff --git a/src/api/scene/lightfx-bake.ts b/src/api/scene/lightfx-bake.ts index 2d635f0a6..8e814afc7 100644 --- a/src/api/scene/lightfx-bake.ts +++ b/src/api/scene/lightfx-bake.ts @@ -2,9 +2,10 @@ import { COMMON_STATUS, CommonResultType } from '../base/schema-base'; import { description, param, result, title, tool } from '../decorator/decorator'; import { Scene } from '../../core/scene'; import { - SchemaClearCountResult, SchemaLightFXCancelResult, SchemaLightmapBakeOptions, SchemaLightmapBakeResult, + SchemaClearCountResult, SchemaLightFXCancelResult, SchemaLightmapBakeInfo, + SchemaLightmapBakeOptions, SchemaLightmapBakeResult, SchemaLightmapClearOptions, SchemaLightProbeBakeOptions, SchemaLightProbeBakeResult, SchemaLightProbeClearOptions, - TLightmapBakeOptions, TLightmapBakeResult, TLightProbeBakeOptions, TLightProbeBakeResult, + TLightmapBakeInfo, TLightmapBakeOptions, TLightmapBakeResult, TLightProbeBakeOptions, TLightProbeBakeResult, } from './lightfx-bake-schema'; async function execute(operation: () => Promise): Promise> { @@ -37,6 +38,14 @@ export class LightFXBakeApi { return execute(() => Scene.LightmapBake.bake(options)); } + @tool('scene-query-lightmap-bake-info') + @title('Query lightmap bake information') + @description('Query lightmap textures and bake flags currently bound to meshes and terrains in the active scene.') + @result(SchemaLightmapBakeInfo) + queryLightmapBakeInfo(): Promise> { + return execute(() => Scene.LightmapBake.queryBakeInfo()); + } + @tool('scene-clear-lightmap') @title('Clear baked lightmap') @description('Unbind baked lightmaps from the current scene and optionally delete generated assets.') diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts index 522b57929..1c617f523 100644 --- a/src/core/scene/common/lightfx-bake.ts +++ b/src/core/scene/common/lightfx-bake.ts @@ -1,4 +1,5 @@ import type { IServiceEvents } from '../scene-process/service/core'; +import type { ILightmapTextureInfo } from './lightfx-host'; export interface ILightProbeBakeOptions { giScale?: number; @@ -42,6 +43,17 @@ export interface ILightmapBakeResult { durationMs: number; } +export interface ILightmapBakeInfo { + sceneUrl: string; + baked: boolean; + meshCount: number; + terrainCount: number; + highp: boolean; + stationaryMainLight: boolean; + textures: ILightmapTextureInfo[]; + missingTextureUuids: string[]; +} + export interface ILightFXCancelResult { cancelled: boolean; target: 'light-probe' | 'lightmap' | null; @@ -60,9 +72,10 @@ export interface ILightProbeBakeService extends IServiceEvents { export interface ILightmapBakeService extends IServiceEvents { bake(options: ILightmapBakeOptions): Promise; + queryBakeInfo(): Promise; clearBake(options?: { saveScene?: boolean; deleteAssets?: boolean }): Promise<{ clearedCount: number }>; cancel(): Promise; } export type IPublicLightProbeBakeService = Pick; -export type IPublicLightmapBakeService = Pick; +export type IPublicLightmapBakeService = Pick; diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts index 919c2411b..082a71a5e 100644 --- a/src/core/scene/common/lightfx-host.ts +++ b/src/core/scene/common/lightfx-host.ts @@ -75,6 +75,24 @@ export interface IRemoveLightmapAssetsOptions { sceneName: string; } +export interface IQueryLightmapTextureInfoOptions { + uuids: string[]; +} + +export interface ILightmapTextureInfo { + uuid: string; + url: string; + filename: string; + size: number; + createdAt: number; + modifiedAt: number; +} + +export interface IQueryLightmapTextureInfoResult { + textures: ILightmapTextureInfo[]; + missingTextureUuids: string[]; +} + /** * Node-hosted half of LightFX baking. * @@ -90,4 +108,5 @@ export interface ILightFXBakeHostService { rollback(options: ILightFXOperationOptions): Promise; cancel(): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }>; removeLightmapAssets(options: IRemoveLightmapAssetsOptions): Promise; + queryLightmapTextureInfo(options: IQueryLightmapTextureInfoOptions): Promise; } diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index aa4de7606..43804735b 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -8,6 +8,7 @@ import { readFile, readdir, remove, + stat, } from 'fs-extra'; import { basename, dirname, join } from 'path'; import Utils from '../../base/utils'; @@ -18,6 +19,8 @@ import type { ILightFXBakeHostService, ILightFXOperationOptions, ILightFXTextureSource, + IQueryLightmapTextureInfoOptions, + IQueryLightmapTextureInfoResult, IRemoveLightmapAssetsOptions, IResolvedLightFXTextureSource, IResolveLightFXTextureSourceOptions, @@ -79,6 +82,48 @@ export class LightFXBakeHost implements ILightFXBakeHostService { return resolved ? { fileName: resolved.fileName } : null; } + public async queryLightmapTextureInfo( + options: IQueryLightmapTextureInfoOptions, + ): Promise { + if (!options || !Array.isArray(options.uuids) || options.uuids.length > MAX_TEXTURE_SOURCES) { + throw new Error('Invalid Lightmap texture UUID list.'); + } + + const uuids = [...new Set(options.uuids.map((value) => { + if (typeof value !== 'string') { + throw new Error('Invalid Lightmap texture UUID.'); + } + const uuid = Utils.UUID.decompressUUID(value).split('@', 1)[0]; + if (!Utils.UUID.isUUID(uuid)) { + throw new Error('Invalid Lightmap texture UUID.'); + } + return uuid; + }))]; + const textures: IQueryLightmapTextureInfoResult['textures'] = []; + const missingTextureUuids: string[] = []; + for (const uuid of uuids) { + const info = assetManager.queryAssetInfo(uuid); + if (!info?.file || !info.url) { + missingTextureUuids.push(uuid); + continue; + } + try { + const fileStat = await stat(info.file); + textures.push({ + uuid: info.uuid || uuid, + url: info.url, + filename: basename(info.file), + size: fileStat.size, + createdAt: fileStat.birthtimeMs, + modifiedAt: fileStat.mtimeMs, + }); + } catch { + missingTextureUuids.push(uuid); + } + } + return { textures, missingTextureUuids }; + } + public async begin(options: IBeginLightFXBakeOptions): Promise { if (this.operation) { throw new Error(`A ${this.operation.target} LightFX bake is already in progress.`); diff --git a/src/core/scene/main-process/lightfx-bake-renderer.ts b/src/core/scene/main-process/lightfx-bake-renderer.ts index 0b2300cb0..b3664c304 100644 --- a/src/core/scene/main-process/lightfx-bake-renderer.ts +++ b/src/core/scene/main-process/lightfx-bake-renderer.ts @@ -3,7 +3,7 @@ import type { DefaultEventsMap } from 'socket.io/dist/typed-events'; import { SCENE_RENDERER_ROOM, socketService } from '../../../server/socket'; type LightFXModule = 'LightProbeBake' | 'LightmapBake'; -type LightFXMethod = 'bake' | 'clearBake' | 'cancel'; +type LightFXMethod = 'bake' | 'queryBakeInfo' | 'clearBake' | 'cancel'; interface RendererSocketData { sceneUrl?: string; diff --git a/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts b/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts index 3dfe8dbee..2afd7573b 100644 --- a/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts +++ b/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts @@ -21,6 +21,10 @@ export const LightmapBakeProxy: IPublicLightmapBakeService = { 'LightmapBake', 'bake', [options], (options.timeoutMs ?? 600_000) + 30_000, () => Rpc.getInstance().request('LightmapBake', 'bake', [options]), true, ), + queryBakeInfo: () => lightFXBakeRenderer.invoke( + 'LightmapBake', 'queryBakeInfo', [], 120_000, + () => Rpc.getInstance().request('LightmapBake', 'queryBakeInfo'), + ), clearBake: (options) => lightFXBakeRenderer.invoke( 'LightmapBake', 'clearBake', [options], 120_000, () => Rpc.getInstance().request('LightmapBake', 'clearBake', [options]), diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index c3248d97f..1ca20fd11 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -276,7 +276,7 @@ async function setupBrowserInvokeChannel(serverURL: string) { msg: { sceneUrl?: string; module?: 'LightProbeBake' | 'LightmapBake'; - method?: 'bake' | 'clearBake' | 'cancel'; + method?: 'bake' | 'queryBakeInfo' | 'clearBake' | 'cancel'; args?: unknown[]; }, reply: (response: { result?: unknown; sceneUrl?: string; error?: string }) => void, @@ -285,7 +285,7 @@ async function setupBrowserInvokeChannel(serverURL: string) { const methods = msg?.module === 'LightProbeBake' ? new Set(['bake', 'clearBake', 'cancel']) : msg?.module === 'LightmapBake' - ? new Set(['bake', 'clearBake', 'cancel']) + ? new Set(['bake', 'queryBakeInfo', 'clearBake', 'cancel']) : null; if (!methods?.has(msg.method || '')) { throw new Error('Invalid LightFX scene request.'); diff --git a/src/core/scene/scene-process/service/baking/lightfx/host.ts b/src/core/scene/scene-process/service/baking/lightfx/host.ts index f01d790c8..b460f2379 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/host.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/host.ts @@ -4,6 +4,8 @@ import type { IBeginLightFXBakeResult, ILightFXBakeHostService, ILightFXOperationOptions, + IQueryLightmapTextureInfoOptions, + IQueryLightmapTextureInfoResult, IRemoveLightmapAssetsOptions, IResolveLightFXTextureSourceOptions, IResolvedLightFXTextureSource, @@ -22,4 +24,5 @@ export const lightFXBakeHost: ILightFXBakeHostService = { rollback: (options: ILightFXOperationOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'rollback', [options]), cancel: (): Promise<{ cancelled: boolean; target: 'light-probe' | 'lightmap' | null }> => Rpc.getInstance().request('lightFXBakeHost', 'cancel'), removeLightmapAssets: (options: IRemoveLightmapAssetsOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'removeLightmapAssets', [options]), + queryLightmapTextureInfo: (options: IQueryLightmapTextureInfoOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'queryLightmapTextureInfo', [options]), }; diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index 71efe9b4c..e717bebb2 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -1,11 +1,12 @@ import { director, MeshRenderer, Scene, Terrain, Texture2D } from 'cc'; import type { ILightFXBakeEvents, ILightFXCancelResult, ILightmapBakeOptions, - ILightmapBakeResult, ILightmapBakeService, + ILightmapBakeInfo, ILightmapBakeResult, ILightmapBakeService, } from '../../common'; import { Rpc } from '../rpc'; import { lightFXCoordinator } from './baking/lightfx/baker'; import type { LightFXBakeOutput } from './baking/lightfx/baker'; +import { lightFXBakeHost } from './baking/lightfx/host'; import { createDefaultLightFXSettings } from './baking/lightfx/settings'; import { BaseService, register, Service } from './core'; import { loadPreviewAsset } from './preview/asset-reload'; @@ -89,6 +90,48 @@ export class LightmapBakeService extends BaseService impleme } } + async queryBakeInfo(): Promise { + const scene = director.getScene() as Scene | null; + if (!scene) throw new Error('No scene is currently open.'); + + const textureUuids = new Set(); + let meshCount = 0; + let terrainCount = 0; + const addTexture = (texture: any): boolean => { + const uuid = texture?.uuid ?? texture?._uuid; + if (typeof uuid !== 'string' || !uuid) return false; + textureUuids.add(uuid); + return true; + }; + const visit = (node: any): void => { + for (const model of node.getComponents(MeshRenderer) as any[]) { + if (addTexture(model.bakeSettings?.texture)) meshCount += 1; + } + for (const terrain of node.getComponents(Terrain) as any[]) { + let hasLightmap = false; + for (const info of (terrain._lightmapInfos ?? []) as any[]) { + hasLightmap = addTexture(info?.texture) || hasLightmap; + } + if (hasLightmap) terrainCount += 1; + } + node.children.forEach(visit); + }; + visit(scene); + + const assetInfo = await lightFXBakeHost.queryLightmapTextureInfo({ + uuids: [...textureUuids], + }); + return { + sceneUrl: await this.querySceneUrl(), + baked: meshCount > 0 || terrainCount > 0, + meshCount, + terrainCount, + highp: Boolean((scene.globals as any).bakedWithHighpLightmap), + stationaryMainLight: Boolean((scene.globals as any).bakedWithStationaryMainLight), + ...assetInfo, + }; + } + async clearBake(options: { saveScene?: boolean; deleteAssets?: boolean } = {}): Promise<{ clearedCount: number }> { const scene = director.getScene() as Scene | null; if (!scene) throw new Error('No scene is currently open.'); diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts index dedc1463e..cb43fd161 100644 --- a/src/core/scene/test/lightfx-bake-host.test.ts +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -6,6 +6,7 @@ const mockAssetManager = { queryPath: jest.fn(), refreshAsset: jest.fn(), queryUUID: jest.fn(), + queryAssetInfo: jest.fn(), queryAssetMeta: jest.fn(), saveAssetMeta: jest.fn(), }; @@ -42,6 +43,7 @@ describe('LightFXBakeHost', () => { )); mockAssetManager.refreshAsset.mockReset().mockResolvedValue(undefined); mockAssetManager.queryUUID.mockReset(); + mockAssetManager.queryAssetInfo.mockReset(); mockAssetManager.queryAssetMeta.mockReset(); mockAssetManager.saveAssetMeta.mockReset(); mockRunnerRun.mockReset(); @@ -137,6 +139,33 @@ describe('LightFXBakeHost', () => { expect(mockRunnerCancel).not.toHaveBeenCalled(); }); + it('queries display-safe metadata for bound lightmap textures', async () => { + const uuid = '11111111-1111-4111-8111-111111111111'; + const missingUuid = '22222222-2222-4222-8222-222222222222'; + const file = join(assetRoot, 'Lightmap', 'lightmap', 'LFX_Mesh_0000.png'); + await outputFile(file, Buffer.from('lightmap')); + mockAssetManager.queryAssetInfo.mockImplementation((value: string) => value === uuid ? { + uuid, + url: 'db://assets/Lightmap/lightmap/LFX_Mesh_0000.png', + file, + } : null); + + await expect(host.queryLightmapTextureInfo({ + uuids: [`${uuid}@6c48a`, uuid, missingUuid], + })).resolves.toEqual({ + textures: [{ + uuid, + url: 'db://assets/Lightmap/lightmap/LFX_Mesh_0000.png', + filename: 'LFX_Mesh_0000.png', + size: 8, + createdAt: expect.any(Number), + modifiedAt: expect.any(Number), + }], + missingTextureUuids: [missingUuid], + }); + expect(mockAssetManager.queryAssetInfo).toHaveBeenCalledTimes(2); + }); + it('reports cancellation instead of an unknown operation when upload continues after cancel', async () => { const { operationId } = await host.begin({ target: 'light-probe', diff --git a/src/core/scene/test/lightfx-bake-renderer.test.ts b/src/core/scene/test/lightfx-bake-renderer.test.ts index 58252a05f..3bb41e15b 100644 --- a/src/core/scene/test/lightfx-bake-renderer.test.ts +++ b/src/core/scene/test/lightfx-bake-renderer.test.ts @@ -80,6 +80,25 @@ describe('LightFX active scene renderer routing', () => { expect(fallback).toHaveBeenCalledTimes(1); }); + it('routes a lightmap bake-info query to the active renderer', async () => { + const visible = createSocket({ + id: 'visible', + sceneUrl: 'db://assets/Lightmap.scene', + visible: true, + result: { baked: true, textures: [] }, + }); + useSockets([visible]); + + await expect(lightFXBakeRenderer.invoke( + 'LightmapBake', 'queryBakeInfo', [], 120_000, jest.fn(), + )).resolves.toEqual({ baked: true, textures: [] }); + expect(visible.emit).toHaveBeenCalledWith( + 'scene:invoke-lightfx', + expect.objectContaining({ module: 'LightmapBake', method: 'queryBakeInfo' }), + expect.any(Function), + ); + }); + it('does not silently bake in the Scene Worker when the visible renderer has no scene', async () => { useSockets([ createSocket({ id: 'visible', sceneUrl: '', visible: true }), diff --git a/src/core/scene/test/lightmap-bake-info.test.ts b/src/core/scene/test/lightmap-bake-info.test.ts new file mode 100644 index 000000000..e293a90c7 --- /dev/null +++ b/src/core/scene/test/lightmap-bake-info.test.ts @@ -0,0 +1,95 @@ +const mockGetScene = jest.fn(); +const mockQueryLightmapTextureInfo = jest.fn(); +const mockMeshRenderer = class MeshRenderer {}; +const mockTerrain = class Terrain {}; + +jest.mock('cc', () => ({ + director: { getScene: mockGetScene }, + MeshRenderer: mockMeshRenderer, + Scene: class Scene {}, + Terrain: mockTerrain, + Texture2D: class Texture2D {}, +})); +jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ + lightFXCoordinator: {}, +})); +jest.mock('../scene-process/service/baking/lightfx/host', () => ({ + lightFXBakeHost: { + queryLightmapTextureInfo: (...args: unknown[]) => mockQueryLightmapTextureInfo(...args), + }, +})); +jest.mock('../scene-process/service/baking/lightfx/settings', () => ({ + createDefaultLightFXSettings: jest.fn(), +})); +jest.mock('../scene-process/service/preview/asset-reload', () => ({ + loadPreviewAsset: jest.fn(), +})); +jest.mock('../scene-process/rpc', () => ({ + Rpc: { getInstance: jest.fn() }, +})); + +import { LightmapBakeService } from '../scene-process/service/lightmap-bake'; + +function node(models: unknown[] = [], terrains: unknown[] = [], children: unknown[] = []) { + return { + children, + getComponents: jest.fn((type) => type === mockMeshRenderer ? models : type === mockTerrain ? terrains : []), + }; +} + +describe('LightmapBakeService bake information', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('queries unique texture metadata from the current scene bindings', async () => { + const meshTexture = { uuid: '11111111-1111-4111-8111-111111111111@6c48a' }; + const terrainTexture = { uuid: '22222222-2222-4222-8222-222222222222@6c48a' }; + const scene = { + ...node([], [], [ + node([ + { bakeSettings: { texture: meshTexture } }, + { bakeSettings: { texture: meshTexture } }, + ]), + node([], [{ + _lightmapInfos: [ + { texture: meshTexture }, + { texture: terrainTexture }, + ], + }]), + ]), + globals: { + bakedWithHighpLightmap: true, + bakedWithStationaryMainLight: false, + }, + }; + mockGetScene.mockReturnValue(scene); + const textureInfo = { + textures: [{ + uuid: '11111111-1111-4111-8111-111111111111', + url: 'db://assets/Lightmap/lightmap/LFX_Mesh_0000.png', + filename: 'LFX_Mesh_0000.png', + size: 128, + createdAt: 1, + modifiedAt: 2, + }], + missingTextureUuids: ['22222222-2222-4222-8222-222222222222'], + }; + mockQueryLightmapTextureInfo.mockResolvedValue(textureInfo); + const service = new LightmapBakeService(); + jest.spyOn(service as any, 'querySceneUrl').mockResolvedValue('db://assets/Lightmap.scene'); + + await expect(service.queryBakeInfo()).resolves.toEqual({ + sceneUrl: 'db://assets/Lightmap.scene', + baked: true, + meshCount: 2, + terrainCount: 1, + highp: true, + stationaryMainLight: false, + ...textureInfo, + }); + expect(mockQueryLightmapTextureInfo).toHaveBeenCalledWith({ + uuids: [meshTexture.uuid, terrainTexture.uuid], + }); + }); +}); diff --git a/tests/lightfx-bake-api.test.ts b/tests/lightfx-bake-api.test.ts index 157c460f6..db37e851f 100644 --- a/tests/lightfx-bake-api.test.ts +++ b/tests/lightfx-bake-api.test.ts @@ -1,16 +1,36 @@ import 'reflect-metadata'; import { COMMON_STATUS } from '../src/api/base/schema-base'; -import { SchemaLightmapBakeOptions, SchemaLightProbeBakeOptions } from '../src/api/scene/lightfx-bake-schema'; +import { + SchemaLightmapBakeInfo, + SchemaLightmapBakeOptions, + SchemaLightProbeBakeOptions, +} from '../src/api/scene/lightfx-bake-schema'; -const probeBake = jest.fn(); const lightmapBake = jest.fn(); +const probeBake = jest.fn(); const lightmapBake = jest.fn(); const queryLightmapBakeInfo = jest.fn(); jest.mock('../src/api/decorator/decorator', () => ({ description: () => jest.fn(), param: () => jest.fn(), result: () => jest.fn(), title: () => jest.fn(), tool: () => jest.fn() })); -jest.mock('../src/core/scene', () => ({ Scene: { LightProbeBake: { bake: (...args: unknown[]) => probeBake(...args), clearBake: jest.fn(), cancel: jest.fn() }, LightmapBake: { bake: (...args: unknown[]) => lightmapBake(...args), clearBake: jest.fn(), cancel: jest.fn() } } })); +jest.mock('../src/core/scene', () => ({ Scene: { LightProbeBake: { bake: (...args: unknown[]) => probeBake(...args), clearBake: jest.fn(), cancel: jest.fn() }, LightmapBake: { bake: (...args: unknown[]) => lightmapBake(...args), queryBakeInfo: (...args: unknown[]) => queryLightmapBakeInfo(...args), clearBake: jest.fn(), cancel: jest.fn() } } })); import { LightFXBakeApi } from '../src/api/scene/lightfx-bake'; describe('LightFX bake API', () => { - beforeEach(() => { probeBake.mockReset(); lightmapBake.mockReset(); }); + beforeEach(() => { probeBake.mockReset(); lightmapBake.mockReset(); queryLightmapBakeInfo.mockReset(); }); it('validates probe parameters', () => { expect(SchemaLightProbeBakeOptions.parse({ giScale: 8, giSamples: 4096, bounces: 1 })).toMatchObject({ giScale: 8 }); expect(() => SchemaLightProbeBakeOptions.parse({ giSamples: 1 })).toThrow(); expect(() => SchemaLightProbeBakeOptions.parse({ bounces: 5 })).toThrow(); }); it('validates all Creator lightmap calculation parameters', () => { const options = { msaa: 4 as const, resolution: 1024, filter: true, highp: false, giScale: 1, giSamples: 25, giPathLength: 4, aoLevel: 0, aoStrength: .5, aoRadius: 1, aoColor: [136, 136, 136, 255] as [number, number, number, number], threads: 4 }; expect(SchemaLightmapBakeOptions.parse(options)).toEqual(options); }); it('forwards probe bake and wraps success', async () => { const data = { sceneUrl: 'db://assets/a.scene', probeCount: 4, giScale: 1, giSamples: 64, bounces: 1, durationMs: 10 }; probeBake.mockResolvedValue(data); await expect(new LightFXBakeApi().bakeLightProbes({ saveScene: true })).resolves.toEqual({ code: COMMON_STATUS.SUCCESS, data }); expect(probeBake).toHaveBeenCalledWith({ saveScene: true }); }); it('wraps LightFX failure', async () => { lightmapBake.mockRejectedValue(new Error('LightFX failed')); await expect(new LightFXBakeApi().bakeLightmap({})).resolves.toEqual({ code: COMMON_STATUS.FAIL, reason: 'LightFX failed' }); }); + it('queries the current lightmap bake information', async () => { + const data = { + sceneUrl: 'db://assets/Lightmap.scene', baked: true, meshCount: 1, terrainCount: 0, + highp: false, stationaryMainLight: false, + textures: [{ + uuid: 'texture-uuid', url: 'db://assets/Lightmap/lightmap/LFX_Mesh_0000.png', + filename: 'LFX_Mesh_0000.png', size: 1024, createdAt: 1, modifiedAt: 2, + }], + missingTextureUuids: [], + }; + expect(SchemaLightmapBakeInfo.parse(data)).toEqual(data); + queryLightmapBakeInfo.mockResolvedValue(data); + await expect(new LightFXBakeApi().queryLightmapBakeInfo()) + .resolves.toEqual({ code: COMMON_STATUS.SUCCESS, data }); + expect(queryLightmapBakeInfo).toHaveBeenCalledTimes(1); + }); }); From 0abbf6448dac3161d583124a92d6b402c55fbe71 Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Tue, 8 Sep 2026 15:19:18 +0800 Subject: [PATCH 06/64] feat(scene): persist light probe bake settings --- docs/dev/scene/lightfx-bake.md | 14 ++++- .../__snapshots__/dts-snapshot.test.ts.snap | 8 +++ src/api/scene/lightfx-bake-schema.ts | 14 +++-- src/api/scene/lightfx-bake.ts | 2 +- src/core/scene/common/lightfx-bake.ts | 8 +++ .../scene-process/service/light-probe-bake.ts | 60 ++++++++++++++++--- tests/lightfx-bake-api.test.ts | 14 ++++- 7 files changed, 105 insertions(+), 15 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 5940f6fb9..2b4fafa5f 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -33,6 +33,10 @@ MCP API 只负责参数校验和结果封装。场景运行时负责导出场景 "giScale": 8, "giSamples": 4096, "bounces": 1, + "reduceRinging": 0, + "showWireframe": true, + "showConvex": false, + "lightProbeSphereVolume": 1, "saveScene": true, "timeoutMs": 600000 } @@ -46,10 +50,14 @@ MCP API 只负责参数校验和结果封装。场景运行时负责导出场景 | `giScale` | 0–100 | 使用场景 `lightProbeInfo.giScale` | | `giSamples` | 64–65535,整数 | 使用场景 `lightProbeInfo.giSamples` | | `bounces` | 1–4,整数 | 使用场景 `lightProbeInfo.bounces` | +| `reduceRinging` | 0–0.05 | 使用场景 `lightProbeInfo.reduceRinging` | +| `showWireframe` | boolean | 使用场景 `lightProbeInfo.showWireframe` | +| `showConvex` | boolean | 使用场景 `lightProbeInfo.showConvex` | +| `lightProbeSphereVolume` | 0–100 | 使用场景 `lightProbeInfo.lightProbeSphereVolume` | | `saveScene` | boolean | `true` | | `timeoutMs` | 1000–3600000 ms | 600000 ms | -这些覆盖参数只影响本次烘焙,不会修改 LightProbeInfo 的持久化配置。`reduceRinging`、`showWireframe`、`showConvex` 和探针显示尺寸不参与 LightFX 计算,因此不属于该接口参数。 +所有参数均可选,未传入时使用场景当前值。`giScale`、`giSamples` 和 `bounces` 参与 LightFX 计算;`reduceRinging`、`showWireframe`、`showConvex` 和 `lightProbeSphereVolume` 用于烘焙结果后处理或编辑器显示。烘焙成功后,本次的有效参数与 SH 结果作为同一次 Undo 操作写回 `LightProbeInfo`;烘焙失败或取消时保留原场景配置。 成功返回示例: @@ -63,6 +71,10 @@ MCP API 只负责参数校验和结果封装。场景运行时负责导出场景 "giScale": 8, "giSamples": 4096, "bounces": 1, + "reduceRinging": 0, + "showWireframe": true, + "showConvex": false, + "lightProbeSphereVolume": 1, "durationMs": 1630 } } 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 ca7afee61..37d9ad0ad 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 @@ -6448,6 +6448,10 @@ export declare interface ILightProbeBakeOptions { giScale?: number; giSamples?: number; bounces?: number; + reduceRinging?: number; + showWireframe?: boolean; + showConvex?: boolean; + lightProbeSphereVolume?: number; saveScene?: boolean; timeoutMs?: number; } @@ -6457,6 +6461,10 @@ export declare interface ILightProbeBakeResult { giScale: number; giSamples: number; bounces: number; + reduceRinging: number; + showWireframe: boolean; + showConvex: boolean; + lightProbeSphereVolume: number; durationMs: number; } export declare interface ILightProbeBakeService extends IServiceEvents { diff --git a/src/api/scene/lightfx-bake-schema.ts b/src/api/scene/lightfx-bake-schema.ts index be76e0dc3..5f1fa1736 100644 --- a/src/api/scene/lightfx-bake-schema.ts +++ b/src/api/scene/lightfx-bake-schema.ts @@ -6,15 +6,21 @@ const SaveAndTimeout = { }; export const SchemaLightProbeBakeOptions = z.object({ - giScale: z.number().finite().min(0).max(100).optional().describe('GI multiplier for this bake only'), - giSamples: z.number().int().min(64).max(65535).optional().describe('GI probe sample count for this bake only'), - bounces: z.number().int().min(1).max(4).optional().describe('Probe ray bounce count for this bake only'), + giScale: z.number().finite().min(0).max(100).optional().describe('GI multiplier; defaults to the current scene value'), + giSamples: z.number().int().min(64).max(65535).optional().describe('GI probe sample count; defaults to the current scene value'), + bounces: z.number().int().min(1).max(4).optional().describe('Probe ray bounce count; defaults to the current scene value'), + reduceRinging: z.number().finite().min(0).max(0.05).optional().describe('Spherical-harmonic ringing reduction; defaults to the current scene value'), + showWireframe: z.boolean().optional().describe('Show light-probe connections in the scene view; defaults to the current scene value'), + showConvex: z.boolean().optional().describe('Show the light-probe convex hull in the scene view; defaults to the current scene value'), + lightProbeSphereVolume: z.number().finite().min(0).max(100).optional().describe('Light-probe sphere display size; defaults to the current scene value'), ...SaveAndTimeout, }).describe('Light probe bake options'); export const SchemaLightProbeBakeResult = z.object({ sceneUrl: z.string(), probeCount: z.number().int().nonnegative(), - giScale: z.number(), giSamples: z.number().int(), bounces: z.number().int(), durationMs: z.number().nonnegative(), + giScale: z.number(), giSamples: z.number().int(), bounces: z.number().int(), + reduceRinging: z.number(), showWireframe: z.boolean(), showConvex: z.boolean(), lightProbeSphereVolume: z.number(), + durationMs: z.number().nonnegative(), }); export const SchemaLightmapBakeOptions = z.object({ diff --git a/src/api/scene/lightfx-bake.ts b/src/api/scene/lightfx-bake.ts index 8e814afc7..7a20209f6 100644 --- a/src/api/scene/lightfx-bake.ts +++ b/src/api/scene/lightfx-bake.ts @@ -16,7 +16,7 @@ async function execute(operation: () => Promise): Promise> { return execute(() => Scene.LightProbeBake.bake(options)); diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts index 1c617f523..7f4010456 100644 --- a/src/core/scene/common/lightfx-bake.ts +++ b/src/core/scene/common/lightfx-bake.ts @@ -5,6 +5,10 @@ export interface ILightProbeBakeOptions { giScale?: number; giSamples?: number; bounces?: number; + reduceRinging?: number; + showWireframe?: boolean; + showConvex?: boolean; + lightProbeSphereVolume?: number; saveScene?: boolean; timeoutMs?: number; } @@ -15,6 +19,10 @@ export interface ILightProbeBakeResult { giScale: number; giSamples: number; bounces: number; + reduceRinging: number; + showWireframe: boolean; + showConvex: boolean; + lightProbeSphereVolume: number; durationMs: number; } diff --git a/src/core/scene/scene-process/service/light-probe-bake.ts b/src/core/scene/scene-process/service/light-probe-bake.ts index 0dd95b97a..0b6957f3f 100644 --- a/src/core/scene/scene-process/service/light-probe-bake.ts +++ b/src/core/scene/scene-process/service/light-probe-bake.ts @@ -15,6 +15,16 @@ interface ProbeSnapshot { coefficients: Vec3[]; } +interface LightProbeSettings { + giScale: number; + giSamples: number; + bounces: number; + reduceRinging: number; + showWireframe: boolean; + showConvex: boolean; + lightProbeSphereVolume: number; +} + @register('LightProbeBake') export class LightProbeBakeService extends BaseService implements ILightProbeBakeService { async bake(options: ILightProbeBakeOptions = {}): Promise { @@ -27,13 +37,20 @@ export class LightProbeBakeService extends BaseService imple const probes: any[] = info.data?.probes ?? []; if (probes.length < 4) throw new Error('At least four generated light probes are required.'); - const giScale = options.giScale ?? info.giScale; - const giSamples = options.giSamples ?? info.giSamples; - const bounces = options.bounces ?? info.bounces; + const previousSettings = this.getSettings(info); + const settingsToApply: LightProbeSettings = { + giScale: options.giScale ?? previousSettings.giScale, + giSamples: options.giSamples ?? previousSettings.giSamples, + bounces: options.bounces ?? previousSettings.bounces, + reduceRinging: options.reduceRinging ?? previousSettings.reduceRinging, + showWireframe: options.showWireframe ?? previousSettings.showWireframe, + showConvex: options.showConvex ?? previousSettings.showConvex, + lightProbeSphereVolume: options.lightProbeSphereVolume ?? previousSettings.lightProbeSphereVolume, + }; const settings = createDefaultLightFXSettings('light-probe'); - settings.giProbeScale = giScale; - settings.giProbeSamples = giSamples; - settings.giProbePathLength = bounces; + settings.giProbeScale = settingsToApply.giScale; + settings.giProbeSamples = settingsToApply.giSamples; + settings.giProbePathLength = settingsToApply.bounces; const previous = this.snapshot(probes); let output: LightFXBakeOutput | undefined; @@ -44,6 +61,7 @@ export class LightProbeBakeService extends BaseService imple const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Bake light probes' }); try { + this.applySettings(info, settingsToApply); this.applyResult(probes, output); info.onProbeBakeFinished(); await Service.Engine.repaintInEditMode(); @@ -56,10 +74,16 @@ export class LightProbeBakeService extends BaseService imple } this.broadcast('lightfx:bake-end', 'light-probe'); - return { sceneUrl, probeCount: probes.length, giScale, giSamples, bounces, durationMs: Date.now() - started }; + return { + sceneUrl, + probeCount: probes.length, + ...settingsToApply, + durationMs: Date.now() - started, + }; } catch (error) { if (output) await lightFXCoordinator.rollback(output.operationId).catch(() => undefined); this.restore(probes, previous); + this.applySettings(info, previousSettings); info.onProbeBakeFinished(); await Service.Engine.repaintInEditMode(); this.broadcast('lightfx:bake-end', 'light-probe', this.errorMessage(error)); @@ -140,6 +164,28 @@ export class LightProbeBakeService extends BaseService imple }); } + private getSettings(info: any): LightProbeSettings { + return { + giScale: info.giScale, + giSamples: info.giSamples, + bounces: info.bounces, + reduceRinging: info.reduceRinging, + showWireframe: info.showWireframe, + showConvex: info.showConvex, + lightProbeSphereVolume: info.lightProbeSphereVolume, + }; + } + + private applySettings(info: any, settings: LightProbeSettings): void { + info.giScale = settings.giScale; + info.giSamples = settings.giSamples; + info.bounces = settings.bounces; + info.reduceRinging = settings.reduceRinging; + info.showWireframe = settings.showWireframe; + info.showConvex = settings.showConvex; + info.lightProbeSphereVolume = settings.lightProbeSphereVolume; + } + private errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/tests/lightfx-bake-api.test.ts b/tests/lightfx-bake-api.test.ts index db37e851f..ad5ffadff 100644 --- a/tests/lightfx-bake-api.test.ts +++ b/tests/lightfx-bake-api.test.ts @@ -13,9 +13,19 @@ import { LightFXBakeApi } from '../src/api/scene/lightfx-bake'; describe('LightFX bake API', () => { beforeEach(() => { probeBake.mockReset(); lightmapBake.mockReset(); queryLightmapBakeInfo.mockReset(); }); - it('validates probe parameters', () => { expect(SchemaLightProbeBakeOptions.parse({ giScale: 8, giSamples: 4096, bounces: 1 })).toMatchObject({ giScale: 8 }); expect(() => SchemaLightProbeBakeOptions.parse({ giSamples: 1 })).toThrow(); expect(() => SchemaLightProbeBakeOptions.parse({ bounces: 5 })).toThrow(); }); + it('validates all Creator light-probe panel parameters', () => { + const options = { + giScale: 8, giSamples: 4096, bounces: 1, reduceRinging: 0.02, + showWireframe: true, showConvex: false, lightProbeSphereVolume: 2, + }; + expect(SchemaLightProbeBakeOptions.parse(options)).toEqual(options); + expect(() => SchemaLightProbeBakeOptions.parse({ giSamples: 1 })).toThrow(); + expect(() => SchemaLightProbeBakeOptions.parse({ bounces: 5 })).toThrow(); + expect(() => SchemaLightProbeBakeOptions.parse({ reduceRinging: 0.051 })).toThrow(); + expect(() => SchemaLightProbeBakeOptions.parse({ lightProbeSphereVolume: 101 })).toThrow(); + }); it('validates all Creator lightmap calculation parameters', () => { const options = { msaa: 4 as const, resolution: 1024, filter: true, highp: false, giScale: 1, giSamples: 25, giPathLength: 4, aoLevel: 0, aoStrength: .5, aoRadius: 1, aoColor: [136, 136, 136, 255] as [number, number, number, number], threads: 4 }; expect(SchemaLightmapBakeOptions.parse(options)).toEqual(options); }); - it('forwards probe bake and wraps success', async () => { const data = { sceneUrl: 'db://assets/a.scene', probeCount: 4, giScale: 1, giSamples: 64, bounces: 1, durationMs: 10 }; probeBake.mockResolvedValue(data); await expect(new LightFXBakeApi().bakeLightProbes({ saveScene: true })).resolves.toEqual({ code: COMMON_STATUS.SUCCESS, data }); expect(probeBake).toHaveBeenCalledWith({ saveScene: true }); }); + it('forwards probe bake and wraps success', async () => { const data = { sceneUrl: 'db://assets/a.scene', probeCount: 4, giScale: 1, giSamples: 64, bounces: 1, reduceRinging: 0, showWireframe: true, showConvex: false, lightProbeSphereVolume: 1, durationMs: 10 }; probeBake.mockResolvedValue(data); await expect(new LightFXBakeApi().bakeLightProbes({ saveScene: true })).resolves.toEqual({ code: COMMON_STATUS.SUCCESS, data }); expect(probeBake).toHaveBeenCalledWith({ saveScene: true }); }); it('wraps LightFX failure', async () => { lightmapBake.mockRejectedValue(new Error('LightFX failed')); await expect(new LightFXBakeApi().bakeLightmap({})).resolves.toEqual({ code: COMMON_STATUS.FAIL, reason: 'LightFX failed' }); }); it('queries the current lightmap bake information', async () => { const data = { From ef1b2d4da7277e3a78a4a3a5cfaa147062c8a8cf Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Tue, 8 Sep 2026 16:00:57 +0800 Subject: [PATCH 07/64] fix(scene): align lightmap options with Creator --- docs/dev/scene/lightfx-bake.md | 6 +++--- .../__tests__/__snapshots__/dts-snapshot.test.ts.snap | 6 +++--- src/api/scene/lightfx-bake-schema.ts | 6 +++--- src/core/scene/common/lightfx-bake.ts | 6 +++--- tests/lightfx-bake-api.test.ts | 8 +++++++- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 2b4fafa5f..3b38e7140 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -125,13 +125,13 @@ MCP API 只负责参数校验和结果封装。场景运行时负责导出场景 | 参数 | 范围 | CLI 默认值 | | --- | --- | --- | | `msaa` | 1、2、4、8 | 4 | -| `resolution` | 128–8192,整数 | 1024 | +| `resolution` | 128、256、512、1024、2048 | 1024 | | `filter` | boolean | `true` | | `highp` | boolean | `false` | | `giScale` | 0–100 | 1 | | `giSamples` | 1–65535,整数 | 25 | -| `giPathLength` | 1–64,整数 | 4 | -| `aoLevel` | 0–2,整数 | 0 | +| `giPathLength` | 1、2、3、4 | 4 | +| `aoLevel` | 0、1、2 | 0 | | `aoStrength` | ≥ 0 | 0.5 | | `aoRadius` | ≥ 0 | 1 | | `aoColor` | 3 个 RGB 值及可选 Alpha,单项 0–255 | `[136, 136, 136]` | 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 37d9ad0ad..46f279b13 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 @@ -6404,13 +6404,13 @@ export declare interface ILightmapBakeInfo { } export declare interface ILightmapBakeOptions { msaa?: 1 | 2 | 4 | 8; - resolution?: number; + resolution?: 128 | 256 | 512 | 1024 | 2048; filter?: boolean; highp?: boolean; giScale?: number; giSamples?: number; - giPathLength?: number; - aoLevel?: number; + giPathLength?: 1 | 2 | 3 | 4; + aoLevel?: 0 | 1 | 2; aoStrength?: number; aoRadius?: number; aoColor?: [number, number, number, number?]; diff --git a/src/api/scene/lightfx-bake-schema.ts b/src/api/scene/lightfx-bake-schema.ts index 5f1fa1736..ec6755655 100644 --- a/src/api/scene/lightfx-bake-schema.ts +++ b/src/api/scene/lightfx-bake-schema.ts @@ -25,12 +25,12 @@ export const SchemaLightProbeBakeResult = z.object({ export const SchemaLightmapBakeOptions = z.object({ msaa: z.union([z.literal(1), z.literal(2), z.literal(4), z.literal(8)]).optional(), - resolution: z.number().int().min(128).max(8192).optional(), + resolution: z.union([z.literal(128), z.literal(256), z.literal(512), z.literal(1024), z.literal(2048)]).optional(), filter: z.boolean().optional(), highp: z.boolean().optional(), giScale: z.number().finite().min(0).max(100).optional(), giSamples: z.number().int().min(1).max(65535).optional(), - giPathLength: z.number().int().min(1).max(64).optional(), - aoLevel: z.number().int().min(0).max(2).optional(), + giPathLength: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional(), + aoLevel: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(), aoStrength: z.number().finite().min(0).optional(), aoRadius: z.number().finite().min(0).optional(), aoColor: z.tuple([z.number().min(0).max(255), z.number().min(0).max(255), z.number().min(0).max(255), z.number().min(0).max(255).optional()]).optional(), diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts index 7f4010456..69035792b 100644 --- a/src/core/scene/common/lightfx-bake.ts +++ b/src/core/scene/common/lightfx-bake.ts @@ -28,13 +28,13 @@ export interface ILightProbeBakeResult { export interface ILightmapBakeOptions { msaa?: 1 | 2 | 4 | 8; - resolution?: number; + resolution?: 128 | 256 | 512 | 1024 | 2048; filter?: boolean; highp?: boolean; giScale?: number; giSamples?: number; - giPathLength?: number; - aoLevel?: number; + giPathLength?: 1 | 2 | 3 | 4; + aoLevel?: 0 | 1 | 2; aoStrength?: number; aoRadius?: number; aoColor?: [number, number, number, number?]; diff --git a/tests/lightfx-bake-api.test.ts b/tests/lightfx-bake-api.test.ts index ad5ffadff..94976a6ab 100644 --- a/tests/lightfx-bake-api.test.ts +++ b/tests/lightfx-bake-api.test.ts @@ -24,7 +24,13 @@ describe('LightFX bake API', () => { expect(() => SchemaLightProbeBakeOptions.parse({ reduceRinging: 0.051 })).toThrow(); expect(() => SchemaLightProbeBakeOptions.parse({ lightProbeSphereVolume: 101 })).toThrow(); }); - it('validates all Creator lightmap calculation parameters', () => { const options = { msaa: 4 as const, resolution: 1024, filter: true, highp: false, giScale: 1, giSamples: 25, giPathLength: 4, aoLevel: 0, aoStrength: .5, aoRadius: 1, aoColor: [136, 136, 136, 255] as [number, number, number, number], threads: 4 }; expect(SchemaLightmapBakeOptions.parse(options)).toEqual(options); }); + it('validates all Creator lightmap calculation parameters', () => { + const options = { msaa: 4 as const, resolution: 1024, filter: true, highp: false, giScale: 1, giSamples: 25, giPathLength: 4, aoLevel: 0, aoStrength: .5, aoRadius: 1, aoColor: [136, 136, 136, 255] as [number, number, number, number], threads: 4 }; + expect(SchemaLightmapBakeOptions.parse(options)).toEqual(options); + expect(() => SchemaLightmapBakeOptions.parse({ resolution: 4096 })).toThrow(); + expect(() => SchemaLightmapBakeOptions.parse({ giPathLength: 5 })).toThrow(); + expect(() => SchemaLightmapBakeOptions.parse({ aoLevel: 3 })).toThrow(); + }); it('forwards probe bake and wraps success', async () => { const data = { sceneUrl: 'db://assets/a.scene', probeCount: 4, giScale: 1, giSamples: 64, bounces: 1, reduceRinging: 0, showWireframe: true, showConvex: false, lightProbeSphereVolume: 1, durationMs: 10 }; probeBake.mockResolvedValue(data); await expect(new LightFXBakeApi().bakeLightProbes({ saveScene: true })).resolves.toEqual({ code: COMMON_STATUS.SUCCESS, data }); expect(probeBake).toHaveBeenCalledWith({ saveScene: true }); }); it('wraps LightFX failure', async () => { lightmapBake.mockRejectedValue(new Error('LightFX failed')); await expect(new LightFXBakeApi().bakeLightmap({})).resolves.toEqual({ code: COMMON_STATUS.FAIL, reason: 'LightFX failed' }); }); it('queries the current lightmap bake information', async () => { From 3287450139ab51cf4063e63b7b0b12eba149d615 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 11:45:05 +0800 Subject: [PATCH 08/64] =?UTF-8?q?fix(scene):=20=E4=BF=AE=E5=A4=8D=E5=85=89?= =?UTF-8?q?=E7=85=A7=E6=8E=A2=E9=92=88=E7=83=98=E7=84=99=E4=B8=8E=E6=B8=85?= =?UTF-8?q?=E7=90=86=E7=9A=84=E6=92=A4=E9=94=80=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scene-process/service/dump/encode.ts | 2 ++ .../scene/scene-process/service/dump/index.ts | 4 +++ .../service/dump/light-probe-metadata.ts | 15 +++++++++ .../scene/test/light-probe-metadata.test.ts | 32 +++++++++++++++++++ 4 files changed, 53 insertions(+) create mode 100644 src/core/scene/scene-process/service/dump/light-probe-metadata.ts create mode 100644 src/core/scene/test/light-probe-metadata.test.ts diff --git a/src/core/scene/scene-process/service/dump/encode.ts b/src/core/scene/scene-process/service/dump/encode.ts index fc6bbf5fe..1371371a1 100644 --- a/src/core/scene/scene-process/service/dump/encode.ts +++ b/src/core/scene/scene-process/service/dump/encode.ts @@ -6,6 +6,7 @@ declare const EditorExtends: any; import dumpUtil from './utils'; import { getDumpComponentAccess } from './service-access'; import { applyParticleInspectorMetadata } from './particle-inspector-metadata'; +import { withLightProbeCoefficientType } from './light-probe-metadata'; import { DumpDefines } from './dump-defines'; import { IProperty } from '../../../@types/public'; @@ -620,6 +621,7 @@ function _checkObjFlags(node: any, data: INode) { * @param objectKey 输出有效信息,当前数据 key,以便问题排查 */ export function encodeObject(object: any, attributes: any, owner: any = null, objectKey?: string, isTemplate?: boolean): IProperty { + attributes = withLightProbeCoefficientType(attributes, owner, objectKey); const ctor = dumpUtil.getConstructor(object, attributes); let defValue = dumpUtil.getDefault(attributes); diff --git a/src/core/scene/scene-process/service/dump/index.ts b/src/core/scene/scene-process/service/dump/index.ts index b6ad0a9ef..ce968e77a 100644 --- a/src/core/scene/scene-process/service/dump/index.ts +++ b/src/core/scene/scene-process/service/dump/index.ts @@ -174,6 +174,10 @@ class DumpUtil { for (const [globalKey, globalPropertyDump] of Object.entries(propertyDump)) { if (globalPropertyDump) { await this.restoreProperty(node, `_globals.${globalKey}`, globalPropertyDump); + if (globalKey === 'lightProbeInfo' && node instanceof Scene) { + // Restoring SH must also invalidate the models' cached lighting. + node.globals.lightProbeInfo.onProbeBakeFinished(); + } } } } diff --git a/src/core/scene/scene-process/service/dump/light-probe-metadata.ts b/src/core/scene/scene-process/service/dump/light-probe-metadata.ts new file mode 100644 index 000000000..ea4f4ed45 --- /dev/null +++ b/src/core/scene/scene-process/service/dump/light-probe-metadata.ts @@ -0,0 +1,15 @@ +import { js, Vec3 } from 'cc'; + +/** Older engines serialize Vertex.coefficients without declaring its array element type. */ +export function withLightProbeCoefficientType( + attributes: T, + owner: object | null, + key?: string, +): T | (T & { ctor: typeof Vec3 }) { + if ((!('ctor' in attributes) || !attributes.ctor) && key === 'coefficients' && owner && js.getClassName(owner) === 'cc.Vertex') { + // Use the declared SH representation even for an empty/cleared array. Do not + // mutate engine metadata or infer an arbitrary array's type from its first item. + return { ...attributes, ctor: Vec3 }; + } + return attributes; +} diff --git a/src/core/scene/test/light-probe-metadata.test.ts b/src/core/scene/test/light-probe-metadata.test.ts new file mode 100644 index 000000000..9ef7da4e9 --- /dev/null +++ b/src/core/scene/test/light-probe-metadata.test.ts @@ -0,0 +1,32 @@ +jest.mock('cc', () => ({ + Vec3: class Vec3 {}, + js: { getClassName: (object: object) => object.constructor.name === 'Vertex' ? 'cc.Vertex' : 'cc.Other' }, +})); + +import { Vec3 } from 'cc'; +import { withLightProbeCoefficientType } from '../scene-process/service/dump/light-probe-metadata'; + +class Vertex { coefficients: Vec3[] = []; } + +describe('Light probe dump metadata', () => { + it('supplies Vec3 for legacy SH arrays without mutating engine attributes', () => { + const attributes = Object.freeze({ default: () => [], serializable: true, visible: false }); + const owner = new Vertex(); + expect(withLightProbeCoefficientType(attributes, owner, 'coefficients')).toEqual({ ...attributes, ctor: Vec3 }); + expect(attributes).not.toHaveProperty('ctor'); + }); + + it('preserves an engine-provided element constructor', () => { + const attributes = { ctor: Vec3, serializable: true }; + expect(withLightProbeCoefficientType(attributes, new Vertex(), 'coefficients')).toBe(attributes); + }); + + it.each([ + [new Vertex(), 'position'], + [{ coefficients: [] }, 'coefficients'], + [null, 'coefficients'], + ])('does not change unrelated metadata (%p, %s)', (owner, key) => { + const attributes = { ctor: undefined, default: () => [] }; + expect(withLightProbeCoefficientType(attributes, owner, key)).toBe(attributes); + }); +}); From dd589f5750882e68c3e9a4359b23ffce70654b40 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 11:49:05 +0800 Subject: [PATCH 09/64] =?UTF-8?q?fix(scene):=20=E4=BF=9D=E7=95=99=E5=A4=9A?= =?UTF-8?q?=E6=8E=A2=E9=92=88=E7=BB=84=E5=9C=BA=E6=99=AF=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E8=BF=87=E7=A8=8B=E4=B8=AD=E7=9A=84=E7=83=98=E7=84=99=E6=95=B0?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/scene/light-probe-data.ts | 44 ++++++++++ .../scene-process/service/scene/utils.ts | 10 ++- src/core/scene/test/light-probe-data.test.ts | 87 +++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 src/core/scene/scene-process/service/scene/light-probe-data.ts create mode 100644 src/core/scene/test/light-probe-data.test.ts diff --git a/src/core/scene/scene-process/service/scene/light-probe-data.ts b/src/core/scene/scene-process/service/scene/light-probe-data.ts new file mode 100644 index 000000000..f4b14b659 --- /dev/null +++ b/src/core/scene/scene-process/service/scene/light-probe-data.ts @@ -0,0 +1,44 @@ +import { Vec3 } from 'cc'; +import type { Scene } from 'cc'; + +interface SavedProbeCoefficients { + values: Vec3[][]; + next: number; +} + +/** + * Keep deserialized SH alive while LightProbeGroup.onLoad registers groups one + * by one. Older engines resize the saved probe array after each registration, + * truncating later groups before they have had a chance to load. + */ +export function preserveLightProbeCoefficients(scene: Scene): (loaded: Scene) => void { + const byPosition = new Map(); + const keyOf = (position: Readonly) => JSON.stringify([position.x, position.y, position.z]); + for (const probe of scene.globals.lightProbeInfo.data?.probes ?? []) { + const key = keyOf(probe.position); + let entry = byPosition.get(key); + if (!entry) { + entry = { values: [], next: 0 }; + byPosition.set(key, entry); + } + entry.values.push(probe.coefficients.map(value => Vec3.clone(value))); + } + + return loaded => { + // Never apply an old scene's data to a replacement/later editor session. + if (loaded !== scene || byPosition.size === 0) return; + const info = loaded.globals.lightProbeInfo; + let restored = false; + for (const probe of info.data?.probes ?? []) { + const entry = byPosition.get(keyOf(probe.position)); + const values = entry?.values[entry.next++]; + if (!values) continue; + // Match positions, not rebuilt array indices. Consume duplicate positions + // separately and leave new/unmatched probes to the engine's own lifecycle. + probe.coefficients = values.map(value => Vec3.clone(value)); + restored = true; + } + byPosition.clear(); + if (restored) info.onProbeBakeFinished(); + }; +} diff --git a/src/core/scene/scene-process/service/scene/utils.ts b/src/core/scene/scene-process/service/scene/utils.ts index 4e720f7be..02a46eeb1 100644 --- a/src/core/scene/scene-process/service/scene/utils.ts +++ b/src/core/scene/scene-process/service/scene/utils.ts @@ -5,6 +5,7 @@ import dumpUtil from '../dump'; import { encodePrefab } from '../dump/encode'; import type { INode, IPrefab, INodeDumpOptions } from '../../../common'; import type { IScene } from '../../../common/editor/scene'; +import { preserveLightProbeCoefficients } from './light-probe-data'; class SceneUtil { /** 默认超时:1分钟 */ @@ -15,6 +16,8 @@ class SceneUtil { * @param sceneAsset */ runScene(sceneAsset: cc.SceneAsset | cc.Scene): Promise { + const scene = sceneAsset instanceof cc.SceneAsset ? sceneAsset.scene : sceneAsset; + const restoreProbeCoefficients = scene ? preserveLightProbeCoefficients(scene) : undefined; // 重要:清空节点与组件的 path 缓存,否则会出现数据重复的问题 EditorExtends.Node.clear(); EditorExtends.Component.clear(); @@ -29,7 +32,12 @@ class SceneUtil { reject(err ?? new Error('Unknown scene run error')); return; } - resolve(instance); + try { + restoreProbeCoefficients?.(instance); + resolve(instance); + } catch (error) { + reject(error); + } } ); }); diff --git a/src/core/scene/test/light-probe-data.test.ts b/src/core/scene/test/light-probe-data.test.ts new file mode 100644 index 000000000..7b859e720 --- /dev/null +++ b/src/core/scene/test/light-probe-data.test.ts @@ -0,0 +1,87 @@ +jest.mock('cc', () => ({ + Vec3: class Vec3 { + constructor(public x = 0, public y = 0, public z = 0) {} + static clone(v: { x: number; y: number; z: number }) { return new this(v.x, v.y, v.z); } + }, +})); + +import { Vec3 } from 'cc'; +import type { Scene } from 'cc'; +import { preserveLightProbeCoefficients } from '../scene-process/service/scene/light-probe-data'; + +function probe(x: number, coefficient: number | null) { + return { position: new Vec3(x, 0, 0), coefficients: coefficient === null ? [] : Array.from({ length: 9 }, (_, i) => new Vec3(coefficient + i, coefficient, coefficient)) }; +} + +function fixture(probes: ReturnType[]) { + const info = { data: { probes }, onProbeBakeFinished: jest.fn() }; + const scene = { globals: { lightProbeInfo: info } } as unknown as Scene; + return { info, scene }; +} + +describe('Light probe data across scene activation', () => { + it('preserves both groups when activation truncates then expands the probe array', () => { + const original = Array.from({ length: 43 }, (_, i) => probe(i, i + 1)); + const { scene, info } = fixture(original.slice()); + const restore = preserveLightProbeCoefficients(scene); + info.data.probes.length = 16; + info.data.probes.push(...Array.from({ length: 27 }, (_, i) => probe(i + 16, 0))); + restore(scene); + expect(info.data.probes).toEqual(original); + expect(info.onProbeBakeFinished).toHaveBeenCalledTimes(1); + }); + + it('matches reordered positions and independently consumes duplicates', () => { + const originals = [probe(1, 10), probe(1, 20), probe(2, 30)]; + const { scene, info } = fixture(originals.slice()); + const restore = preserveLightProbeCoefficients(scene); + info.data.probes = [probe(2, 0), probe(1, 0), probe(1, 0)]; + restore(scene); + expect(info.data.probes).toEqual([originals[2], originals[0], originals[1]]); + }); + + it('copies values before activation mutates existing vertices in place', () => { + const { scene, info } = fixture([probe(1, 10)]); + const restore = preserveLightProbeCoefficients(scene); + info.data.probes[0].coefficients[0].x = 999; + restore(scene); + expect(info.data.probes).toEqual([probe(1, 10)]); + }); + + it('preserves cleared arrays rather than restoring zero-filled baked results', () => { + const { scene, info } = fixture([probe(1, null), probe(2, null)]); + const restore = preserveLightProbeCoefficients(scene); + info.data.probes = [probe(1, 0), probe(2, 0)]; + restore(scene); + expect(info.data.probes).toEqual([probe(1, null), probe(2, null)]); + }); + + it('does not apply a saved coefficient to a new position or excess duplicate', () => { + const { scene, info } = fixture([probe(1, 10)]); + const restore = preserveLightProbeCoefficients(scene); + info.data.probes = [probe(2, 0), probe(1, 0), probe(1, 0)]; + restore(scene); + expect(info.data.probes).toEqual([probe(2, 0), probe(1, 10), probe(1, 0)]); + }); + + it('does not write to a different scene or reapply a consumed snapshot', () => { + const first = fixture([probe(1, 10)]); + const second = fixture([probe(1, 0)]); + const restore = preserveLightProbeCoefficients(first.scene); + restore(second.scene); + expect(second.info.data.probes).toEqual([probe(1, 0)]); + expect(second.info.onProbeBakeFinished).not.toHaveBeenCalled(); + restore(first.scene); + first.info.data.probes = [probe(1, null)]; + restore(first.scene); + expect(first.info.data.probes).toEqual([probe(1, null)]); + expect(first.info.onProbeBakeFinished).toHaveBeenCalledTimes(1); + }); + + it('leaves empty scenes and their notifications untouched', () => { + const { scene, info } = fixture([]); + preserveLightProbeCoefficients(scene)(scene); + expect(info.data.probes).toEqual([]); + expect(info.onProbeBakeFinished).not.toHaveBeenCalled(); + }); +}); From 8f5e814518933d6a0336741036e279beef8c3ff5 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 12:04:40 +0800 Subject: [PATCH 10/64] =?UTF-8?q?feat(scene):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=85=89=E7=85=A7=E6=8E=A2=E9=92=88=E5=87=B8=E5=8C=85=E4=B8=8E?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E6=B3=95=E7=BA=BF=E7=BB=98=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/light-probe-group/index.ts | 44 ++++++++++++ .../service/gizmo/utils/light-probe-convex.ts | 71 +++++++++++++++++++ .../scene/test/light-probe-convex.test.ts | 58 +++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 src/core/scene/scene-process/service/gizmo/utils/light-probe-convex.ts create mode 100644 src/core/scene/test/light-probe-convex.test.ts diff --git a/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts b/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts index 50218d721..1ed3bb7a5 100644 --- a/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts +++ b/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts @@ -7,6 +7,7 @@ import BoxController from '../../controller/box'; import ControllerUtils from '../../utils/controller-utils'; import { addMeshToNode, create3DNode, getModel, setMeshColor } from '../../utils/engine-utils'; import { registerGizmo } from '../../gizmo-defines'; +import { buildLightProbeConvex } from '../../utils/light-probe-convex'; // 探针数量超过该阈值时只画包围盒/线框、不逐个建球,避免海量节点 const MAX_PROBE_DOTS = 4096; @@ -30,6 +31,8 @@ class LightProbeGroupComponentGizmo extends GizmoBase { private _controller!: BoxController; private _dotsRoot: Node | null = null; // 探针球容器(跟随节点世界变换) private _wireframeNode: Node | null = null; // 四面体线框(世界坐标、单位阵) + private _convexNode: Node | null = null; + private _normalNode: Node | null = null; private _probesRef: Vec3[] | null = null; private _dotsVolume = -1; // 上次建点用的球体积,用于失效缓存 private _reuseMesh: any = null; @@ -56,6 +59,8 @@ class LightProbeGroupComponentGizmo extends GizmoBase { this._controller.hide(); if (this._dotsRoot) this._dotsRoot.active = false; if (this._wireframeNode) this._wireframeNode.active = false; + if (this._convexNode) this._convexNode.active = false; + if (this._normalNode) this._normalNode.active = false; this._lastInfoSig = ''; } @@ -76,6 +81,12 @@ class LightProbeGroupComponentGizmo extends GizmoBase { this._wireframeNode = create3DNode('LightProbeWireframe'); this._wireframeNode.parent = gizmoRoot; this._wireframeNode.active = false; + this._convexNode = create3DNode('LightProbeConvex'); + this._convexNode.parent = gizmoRoot; + this._convexNode.active = false; + this._normalNode = create3DNode('LightProbeConvexNormals'); + this._normalNode.parent = gizmoRoot; + this._normalNode.active = false; } onControllerMouseDown() { @@ -97,6 +108,7 @@ class LightProbeGroupComponentGizmo extends GizmoBase { this.target.generateLightProbes(); this._rebuildDots(true); this._rebuildWireframe(); + this._rebuildConvex(); this.onComponentChanged(this.target.node); // 引擎重剖分四面体是延迟的,稍后补刷一次线框,避免与球错位(对齐 Creator debounce) const target = this.target; @@ -104,6 +116,7 @@ class LightProbeGroupComponentGizmo extends GizmoBase { if (this.target === target) { this._rebuildDots(true); this._rebuildWireframe(); + this._rebuildConvex(); } }, 250); } @@ -148,6 +161,8 @@ class LightProbeGroupComponentGizmo extends GizmoBase { this._controller.hide(); if (this._dotsRoot) this._dotsRoot.active = false; if (this._wireframeNode) this._wireframeNode.active = false; + if (this._convexNode) this._convexNode.active = false; + if (this._normalNode) this._normalNode.active = false; return; } @@ -177,6 +192,7 @@ class LightProbeGroupComponentGizmo extends GizmoBase { } this._rebuildDots(false); this._rebuildWireframe(); + this._rebuildConvex(); } private _getLightProbeInfo(): any { @@ -261,6 +277,29 @@ class LightProbeGroupComponentGizmo extends GizmoBase { ControllerUtils.drawLines(this._wireframeNode, positions, indices, WIREFRAME_COLOR); } + private _rebuildConvex() { + if (!this._convexNode || !this._normalNode) return; + const info = this._getLightProbeInfo(); + const data = info?.data; + this._convexNode.active = false; + this._normalNode.active = false; + if (!this.target || !info?.showConvex || !data || data.empty?.()) return; + const geometry = buildLightProbeConvex(data.probes ?? [], data.tetrahedrons ?? []); + for (const node of [this._convexNode, this._normalNode]) { + node.setWorldPosition(0, 0, 0); + node.setRotationFromEuler(0, 0, 0); + node.setWorldScale(1, 1, 1); + } + if (geometry.indices.length) { + ControllerUtils.drawLines(this._convexNode, geometry.positions, geometry.indices, WIREFRAME_COLOR); + this._convexNode.active = true; + } + if (geometry.normalIndices.length) { + ControllerUtils.drawLines(this._normalNode, geometry.normalPositions, geometry.normalIndices, PROBE_COLOR); + this._normalNode.active = true; + } + } + onTargetUpdate() { this.updateControllerData(); } @@ -296,12 +335,17 @@ class LightProbeGroupComponentGizmo extends GizmoBase { info ? (info.lightProbeSphereVolume ?? 1) : 1, info ? (info.showProbe ?? true) : true, info ? (info.showWireframe ?? true) : true, + info ? (info.showConvex ?? false) : false, data?.tetrahedrons?.length ?? 0, data?.probes?.length ?? 0, ].join('|'); } onDestroy() { + this._convexNode?.destroy(); + this._convexNode = null; + this._normalNode?.destroy(); + this._normalNode = null; if (this._dotsRoot) { this._dotsRoot.destroy(); this._dotsRoot = null; diff --git a/src/core/scene/scene-process/service/gizmo/utils/light-probe-convex.ts b/src/core/scene/scene-process/service/gizmo/utils/light-probe-convex.ts new file mode 100644 index 000000000..c2b438935 --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/utils/light-probe-convex.ts @@ -0,0 +1,71 @@ +import { Vec3 } from 'cc'; + +interface ProbeVertex { + position: Readonly; + normal: Readonly; +} + +interface ProbeTetrahedron { + vertex0: number; + vertex1: number; + vertex2: number; + vertex3: number; +} + +const finite = (v: Readonly) => Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z); + +/** Outer cells (-1 and -2) carry convex boundary triangles; inner tetrahedra do not. */ +export function buildLightProbeConvex(vertices: readonly ProbeVertex[], tetrahedrons: readonly ProbeTetrahedron[]) { + const positions: Vec3[] = []; + const indices: number[] = []; + const normalPositions: Vec3[] = []; + const normalIndices: number[] = []; + const vertexMap = new Map(); + const seenEdges = new Set(); + for (const tet of tetrahedrons) { + if (tet.vertex3 !== -1 && tet.vertex3 !== -2) continue; + const ids = [tet.vertex0, tet.vertex1, tet.vertex2]; + if (new Set(ids).size !== 3 || ids.some(id => !Number.isInteger(id) || id < 0 || id >= vertices.length || !finite(vertices[id].position))) continue; + const [a, b, c] = ids.map(id => vertices[id].position); + const ab = new Vec3(b.x - a.x, b.y - a.y, b.z - a.z); + const ac = new Vec3(c.x - a.x, c.y - a.y, c.z - a.z); + const area = Math.hypot(ab.y * ac.z - ab.z * ac.y, ab.z * ac.x - ab.x * ac.z, ab.x * ac.y - ab.y * ac.x); + if (!Number.isFinite(area) || area === 0) continue; + for (const id of ids) { + if (!vertexMap.has(id)) { + vertexMap.set(id, positions.length); + positions.push(Vec3.clone(vertices[id].position)); + } + } + for (let i = 0; i < 3; i++) { + const from = ids[i]; + const to = ids[(i + 1) % 3]; + const edge = from < to ? `${from}:${to}` : `${to}:${from}`; + if (seenEdges.has(edge)) continue; + seenEdges.add(edge); + indices.push(vertexMap.get(from)!, vertexMap.get(to)!); + } + } + + if (positions.length) { + const min = Vec3.clone(positions[0]); + const max = Vec3.clone(positions[0]); + for (const position of positions) { + Vec3.min(min, min, position); + Vec3.max(max, max, position); + } + // A scene-relative display length, independent of node scale and probe sphere size. + const length = Math.max(Math.hypot(max.x - min.x, max.y - min.y, max.z - min.z) * 0.08, 0.01); + for (const id of vertexMap.keys()) { + const { position, normal } = vertices[id]; + const magnitude = Math.hypot(normal.x, normal.y, normal.z); + if (!finite(normal) || !Number.isFinite(length) || !Number.isFinite(magnitude) || magnitude === 0) continue; + const scale = length / magnitude; + const end = new Vec3(position.x + normal.x * scale, position.y + normal.y * scale, position.z + normal.z * scale); + if (!finite(end)) continue; + normalIndices.push(normalPositions.length, normalPositions.length + 1); + normalPositions.push(Vec3.clone(position), end); + } + } + return { positions, indices, normalPositions, normalIndices }; +} diff --git a/src/core/scene/test/light-probe-convex.test.ts b/src/core/scene/test/light-probe-convex.test.ts new file mode 100644 index 000000000..59df8bc71 --- /dev/null +++ b/src/core/scene/test/light-probe-convex.test.ts @@ -0,0 +1,58 @@ +jest.mock('cc', () => ({ + Vec3: class Vec3 { + constructor(public x = 0, public y = 0, public z = 0) {} + static clone(v: { x: number; y: number; z: number }) { return new this(v.x, v.y, v.z); } + static min(out: Vec3, a: Vec3, b: Vec3) { out.x = Math.min(a.x, b.x); out.y = Math.min(a.y, b.y); out.z = Math.min(a.z, b.z); } + static max(out: Vec3, a: Vec3, b: Vec3) { out.x = Math.max(a.x, b.x); out.y = Math.max(a.y, b.y); out.z = Math.max(a.z, b.z); } + }, +})); + +import { Vec3 } from 'cc'; +import { buildLightProbeConvex } from '../scene-process/service/gizmo/utils/light-probe-convex'; + +const vertex = (x: number, y: number, z: number) => ({ position: new Vec3(x, y, z), normal: new Vec3(1, 0, 0) }); +const tet = (a: number, b: number, c: number, d: number) => ({ vertex0: a, vertex1: b, vertex2: c, vertex3: d }); + +describe('Light probe convex display geometry', () => { + const vertices = [vertex(0, 0, 0), vertex(1, 0, 0), vertex(0, 1, 0), vertex(0, 0, 1), vertex(0.1, 0.1, 0.1)]; + + it('draws only outer faces, deduplicates edges and emits one normal per boundary vertex', () => { + const result = buildLightProbeConvex(vertices, [tet(0, 1, 2, 4), tet(0, 1, 2, -1), tet(0, 2, 3, -2), tet(2, 1, 0, -1)]); + expect(result.positions).toEqual(vertices.slice(0, 4).map(v => v.position)); + expect(result.indices).toHaveLength(10); + expect(result.normalIndices).toHaveLength(8); + for (let i = 0; i < 4; i++) { + expect(result.normalPositions[i * 2]).toEqual(vertices[i].position); + expect(result.normalPositions[i * 2 + 1].x).toBeCloseTo(vertices[i].position.x + Math.sqrt(3) * 0.08); + } + }); + + it('does not treat inner tetrahedron edges as a convex hull', () => { + expect(buildLightProbeConvex(vertices, [tet(0, 1, 2, 3)]).indices).toEqual([]); + }); + + it('skips invalid indices, repeated vertices, non-finite points and collinear faces', () => { + const points = [...vertices, vertex(NaN, 0, 0), vertex(2, 0, 0)]; + const result = buildLightProbeConvex(points, [tet(0, 0, 2, -1), tet(-1, 1, 2, -1), tet(0, 1, 99, -1), tet(0, 1, 1.5, -1), tet(0, 1, 5, -1), tet(0, 1, 6, -1)]); + expect(result).toEqual({ positions: [], indices: [], normalPositions: [], normalIndices: [] }); + }); + + it('does not draw zero or invalid normals, and does not mutate engine data', () => { + const points = vertices.slice(0, 3).map(v => ({ position: Vec3.clone(v.position), normal: new Vec3() })); + points[1].normal.x = Infinity; + points[2].normal.x = 4; + const before = points.map(v => [v.position.x, v.position.y, v.position.z, v.normal.x]); + const result = buildLightProbeConvex(points, [tet(0, 1, 2, -1)]); + expect(result.indices).toHaveLength(6); + expect(result.normalIndices).toHaveLength(2); + expect(points.map(v => [v.position.x, v.position.y, v.position.z, v.normal.x])).toEqual(before); + expect(result.positions[0]).not.toBe(points[0].position); + }); + + it('keeps world-space coordinates and is independent of input ordering', () => { + const points = vertices.slice(0, 3).map(v => ({ position: new Vec3(v.position.x + 100, v.position.y + 200, v.position.z + 300), normal: v.normal })); + const result = buildLightProbeConvex(points, [tet(2, 0, 1, -2)]); + expect(result.positions).toEqual([points[2].position, points[0].position, points[1].position]); + expect(result.indices).toEqual([0, 1, 1, 2, 2, 0]); + }); +}); From 7b419971610058fbbc617796a224e6a2cdebede1 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 12:07:51 +0800 Subject: [PATCH 11/64] =?UTF-8?q?fix(scene):=20=E4=B8=B2=E8=A1=8C=E5=8C=96?= =?UTF-8?q?=E5=85=89=E7=85=A7=E7=83=98=E7=84=99=E4=B8=8E=E6=B8=85=E7=90=86?= =?UTF-8?q?=E7=9A=84=E5=9C=BA=E6=99=AF=E4=BA=8B=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/baking/lightfx/scene-operation.ts | 23 ++++++++++ .../scene-process/service/light-probe-bake.ts | 9 ++++ .../scene-process/service/lightmap-bake.ts | 9 ++++ .../test/lightfx-scene-entrances.test.ts | 34 ++++++++++++++ .../test/lightfx-scene-operation.test.ts | 46 +++++++++++++++++++ 5 files changed, 121 insertions(+) create mode 100644 src/core/scene/scene-process/service/baking/lightfx/scene-operation.ts create mode 100644 src/core/scene/test/lightfx-scene-entrances.test.ts create mode 100644 src/core/scene/test/lightfx-scene-operation.test.ts diff --git a/src/core/scene/scene-process/service/baking/lightfx/scene-operation.ts b/src/core/scene/scene-process/service/baking/lightfx/scene-operation.ts new file mode 100644 index 000000000..a1b87d7ec --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/scene-operation.ts @@ -0,0 +1,23 @@ +import type { LightFXBakeTarget } from './types'; + +/** Scene-local transaction guard. This does not replace the shared Node-host operation lease. */ +export class LightFXSceneOperation { + private active: { target: LightFXBakeTarget; action: 'bake' | 'clear' } | null = null; + + async run(target: LightFXBakeTarget, action: 'bake' | 'clear', operation: () => Promise): Promise { + if (this.active) { + throw new Error(`A ${this.active.target} LightFX ${this.active.action} operation is already in progress.`); + } + // Reserve before invoking user code or reaching its first await. Rejected operations must + // not enter snapshot/rollback code belonging to the current owner. + const owner = { target, action }; + this.active = owner; + try { + return await operation(); + } finally { + if (this.active === owner) this.active = null; + } + } +} + +export const lightFXSceneOperation = new LightFXSceneOperation(); diff --git a/src/core/scene/scene-process/service/light-probe-bake.ts b/src/core/scene/scene-process/service/light-probe-bake.ts index 0b6957f3f..80f5651ac 100644 --- a/src/core/scene/scene-process/service/light-probe-bake.ts +++ b/src/core/scene/scene-process/service/light-probe-bake.ts @@ -8,6 +8,7 @@ import type { } from '../../common'; import { lightFXCoordinator, LightFXBakeOutput } from './baking/lightfx/baker'; import { createDefaultLightFXSettings } from './baking/lightfx/settings'; +import { lightFXSceneOperation } from './baking/lightfx/scene-operation'; import { BaseService, register, Service } from './core'; interface ProbeSnapshot { @@ -28,6 +29,10 @@ interface LightProbeSettings { @register('LightProbeBake') export class LightProbeBakeService extends BaseService implements ILightProbeBakeService { async bake(options: ILightProbeBakeOptions = {}): Promise { + return lightFXSceneOperation.run('light-probe', 'bake', () => this.bakeExclusive(options)); + } + + private async bakeExclusive(options: ILightProbeBakeOptions): Promise { const started = Date.now(); const scene = director.getScene() as Scene | null; if (!scene) throw new Error('No scene is currently open.'); @@ -92,6 +97,10 @@ export class LightProbeBakeService extends BaseService imple } async clearBake(options: { saveScene?: boolean } = {}): Promise<{ probeCount: number }> { + return lightFXSceneOperation.run('light-probe', 'clear', () => this.clearBakeExclusive(options)); + } + + private async clearBakeExclusive(options: { saveScene?: boolean }): Promise<{ probeCount: number }> { const scene = director.getScene(); if (!scene) throw new Error('No scene is currently open.'); const info: any = scene.globals.lightProbeInfo; diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index e717bebb2..bc2d3fd4e 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -8,6 +8,7 @@ import { lightFXCoordinator } from './baking/lightfx/baker'; import type { LightFXBakeOutput } from './baking/lightfx/baker'; import { lightFXBakeHost } from './baking/lightfx/host'; import { createDefaultLightFXSettings } from './baking/lightfx/settings'; +import { lightFXSceneOperation } from './baking/lightfx/scene-operation'; import { BaseService, register, Service } from './core'; import { loadPreviewAsset } from './preview/asset-reload'; @@ -21,6 +22,10 @@ interface LightmapBinding { @register('LightmapBake') export class LightmapBakeService extends BaseService implements ILightmapBakeService { async bake(options: ILightmapBakeOptions = {}): Promise { + return lightFXSceneOperation.run('lightmap', 'bake', () => this.bakeExclusive(options)); + } + + private async bakeExclusive(options: ILightmapBakeOptions): Promise { const started = Date.now(); const scene = director.getScene() as Scene | null; if (!scene) throw new Error('No scene is currently open.'); @@ -133,6 +138,10 @@ export class LightmapBakeService extends BaseService impleme } async clearBake(options: { saveScene?: boolean; deleteAssets?: boolean } = {}): Promise<{ clearedCount: number }> { + return lightFXSceneOperation.run('lightmap', 'clear', () => this.clearBakeExclusive(options)); + } + + private async clearBakeExclusive(options: { saveScene?: boolean; deleteAssets?: boolean }): Promise<{ clearedCount: number }> { const scene = director.getScene() as Scene | null; if (!scene) throw new Error('No scene is currently open.'); diff --git a/src/core/scene/test/lightfx-scene-entrances.test.ts b/src/core/scene/test/lightfx-scene-entrances.test.ts new file mode 100644 index 000000000..fa612d763 --- /dev/null +++ b/src/core/scene/test/lightfx-scene-entrances.test.ts @@ -0,0 +1,34 @@ +const mockGetScene = jest.fn(); +jest.mock('cc', () => ({ director: { getScene: mockGetScene } })); +jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: {} })); +jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: {} })); +jest.mock('../scene-process/service/baking/lightfx/settings', () => ({ createDefaultLightFXSettings: jest.fn() })); +jest.mock('../scene-process/service/preview/asset-reload', () => ({ loadPreviewAsset: jest.fn() })); +jest.mock('../scene-process/rpc', () => ({ Rpc: { getInstance: jest.fn() } })); + +import { LightProbeBakeService } from '../scene-process/service/light-probe-bake'; +import { LightmapBakeService } from '../scene-process/service/lightmap-bake'; +import { lightFXSceneOperation } from '../scene-process/service/baking/lightfx/scene-operation'; + +describe('LightFX service entrance ownership', () => { + it('rejects all four entrances before querying scene, snapshotting or rolling back another owner', async () => { + const probe = new LightProbeBakeService(); + const lightmap = new LightmapBakeService(); + await lightFXSceneOperation.run('lightmap', 'clear', async () => { + for (const invoke of [() => probe.bake(), () => probe.clearBake(), () => lightmap.bake(), () => lightmap.clearBake()]) { + await expect(invoke()).rejects.toThrow('lightmap LightFX clear operation is already in progress'); + } + }); + expect(mockGetScene).not.toHaveBeenCalled(); + }); + + it('releases service preflight failures so all following entrances may run', async () => { + mockGetScene.mockReturnValue(null); + const probe = new LightProbeBakeService(); + const lightmap = new LightmapBakeService(); + for (const invoke of [() => probe.bake(), () => probe.clearBake(), () => lightmap.bake(), () => lightmap.clearBake()]) { + await expect(invoke()).rejects.toThrow('No scene is currently open.'); + } + expect(mockGetScene).toHaveBeenCalledTimes(4); + }); +}); diff --git a/src/core/scene/test/lightfx-scene-operation.test.ts b/src/core/scene/test/lightfx-scene-operation.test.ts new file mode 100644 index 000000000..98c93f511 --- /dev/null +++ b/src/core/scene/test/lightfx-scene-operation.test.ts @@ -0,0 +1,46 @@ +import { LightFXSceneOperation } from '../scene-process/service/baking/lightfx/scene-operation'; + +const targets = ['light-probe', 'lightmap'] as const; +const actions = ['bake', 'clear'] as const; + +describe('LightFX scene-local transactions', () => { + for (const target of targets) for (const action of actions) { + it(`${target} ${action} excludes all four entrances until the entire transaction settles`, async () => { + const guard = new LightFXSceneOperation(); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const current = guard.run(target, action, async () => { await held; return 42; }); + const rejected = jest.fn(async () => 0); + for (const otherTarget of targets) for (const otherAction of actions) { + await expect(guard.run(otherTarget, otherAction, rejected)).rejects.toThrow(`${target} LightFX ${action}`); + } + expect(rejected).not.toHaveBeenCalled(); + release(); + await expect(current).resolves.toBe(42); + await expect(guard.run('light-probe', 'clear', async () => 7)).resolves.toBe(7); + }); + } + + it('reserves before the first await and keeps ownership during asynchronous failure cleanup', async () => { + const guard = new LightFXSceneOperation(); + let finishCleanup!: () => void; + const cleanup = new Promise(resolve => { finishCleanup = resolve; }); + const current = guard.run('light-probe', 'bake', async () => { + try { + await expect(guard.run('lightmap', 'clear', async () => 1)).rejects.toThrow('already in progress'); + throw new Error('Bake failed'); + } finally { await cleanup; } + }); + const failure = expect(current).rejects.toThrow('Bake failed'); + await expect(guard.run('light-probe', 'clear', async () => 1)).rejects.toThrow('already in progress'); + finishCleanup(); + await failure; + await expect(guard.run('lightmap', 'clear', async () => 2)).resolves.toBe(2); + }); + + it('releases a synchronous exception without masking it', async () => { + const guard = new LightFXSceneOperation(); + await expect(guard.run('lightmap', 'bake', () => { throw new Error('Prepare failed'); })).rejects.toThrow('Prepare failed'); + await expect(guard.run('lightmap', 'bake', async () => true)).resolves.toBe(true); + }); +}); From d2861b026e7348572fce5c1d5ae8b89d3129ce4b Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 12:18:23 +0800 Subject: [PATCH 12/64] =?UTF-8?q?fix(scene):=20=E8=A1=A5=E9=BD=90=E8=B7=A8?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=AE=9E=E4=BE=8B=E7=9A=84=E5=85=89=E7=85=A7?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E4=BA=92=E6=96=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 10 ++- src/core/scene/common/lightfx-host.ts | 14 ++++ .../scene/main-process/lightfx-bake-host.ts | 57 +++++++++++++- .../service/baking/lightfx/baker.ts | 4 +- .../service/baking/lightfx/host.ts | 2 + .../service/baking/lightfx/scene-operation.ts | 29 ++++++- src/core/scene/test/lightfx-bake-host.test.ts | 78 ++++++++++++++++++- .../test/lightfx-scene-entrances.test.ts | 5 +- .../test/lightfx-scene-operation.test.ts | 44 +++++++++++ 9 files changed, 234 insertions(+), 9 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 3b38e7140..755df8436 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -18,11 +18,19 @@ MCP API 只负责参数校验和结果封装。场景运行时负责导出场景 1. 当前场景必须是已保存的 `.scene` 资产;不支持未保存场景和 prefab。 2. Light Probe 烘焙前,场景中需要至少 4 个已生成的有效探针。 3. Lightmap 烘焙前,需要在 MeshRenderer、SkinnedMeshRenderer 或 Terrain 上配置有效的烘焙设置。 -4. 同一时间只允许运行一个 LightFX 烘焙任务。 +4. 同一 Scene host 下,Light Probe/Lightmap 的 Bake/Clear 共享事务预留;导出、结果应用、保存、Undo、失败恢复与可选资产清理期间拒绝新的冲突操作。 5. 在 Pink 中调用时,目标场景必须已在当前可见的场景视图中加载完成;不需要额外调用 `scene-open`。同时存在多个可见场景视图时,应先激活目标场景标签并关闭重复视图。 ## MCP 工具 +### 并发与故障边界 + +Scene runtime 先通过内部 `reserveSceneOperation` 取得宿主生成的事务凭据,业务结束后通过 `releaseSceneOperation` 释放。多个 Webview/worker 共用同一宿主预留;本地锁仍防止同一 runtime 重入。原生任务的 `operationId` 与场景事务的 `transactionId` 不同,原生 commit/rollback 结束不代表上层场景回写已经结束。内部凭据不是公开任务查询接口,也不是用户认证机制。 + +宿主校验事务凭据、目标和动作;错误或已过期的凭据不能开始新的原生烘焙/清理,重复释放旧事务不能释放新持有者。没有场景预留的旧原生 begin 入口仍独占原生操作;旧资产删除入口也会在删除及 Asset DB 刷新期间临时预留。旧 renderer 若完全绕过新增协议执行内存 Clear,并不受此机制保护,集成时必须统一运行产物版本。 + +运行实例失联或释放失败时采用 fail-closed:宿主不自动超时放开场景预留,以免暂停的旧实例恢复后与新任务同时写回。此时不要自动重试烘焙;先处理原实例并重启其 Scene host。原生回滚失败时保留恢复备份,不得手工删除以“解除忙状态”。自动失联回收、公开任务状态和按任务归属取消尚未包含在这层协议中;现有 Cancel 仍是共享操作,不应直接当作某个面板私有任务的取消按钮。 + ### 烘焙 Light Probe 工具名:`scene-bake-light-probes` diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts index 082a71a5e..2800bb04b 100644 --- a/src/core/scene/common/lightfx-host.ts +++ b/src/core/scene/common/lightfx-host.ts @@ -1,6 +1,16 @@ /** A bake target supported by the native LightFX process. */ export type LightFXBakeTarget = 'light-probe' | 'lightmap'; +/** Internal scene transaction ownership; not a public task or authentication token. */ +export interface IReserveLightFXSceneOperationOptions { + target: LightFXBakeTarget; + action: 'bake' | 'clear'; +} + +export interface ILightFXSceneOperationToken { + transactionId: string; +} + /** JSON-safe reference to a texture needed by a LightFX input file. */ export interface ILightFXTextureSource { uuid: string; @@ -18,6 +28,7 @@ export interface IResolvedLightFXTextureSource { } export interface IBeginLightFXBakeOptions { + transactionId?: string; target: LightFXBakeTarget; sceneName: string; textureSources: ILightFXTextureSource[]; @@ -72,6 +83,7 @@ export interface ILightFXOperationOptions { } export interface IRemoveLightmapAssetsOptions { + transactionId?: string; sceneName: string; } @@ -100,6 +112,8 @@ export interface IQueryLightmapTextureInfoResult { * return value in this contract must remain JSON serializable and must not expose host file paths. */ export interface ILightFXBakeHostService { + reserveSceneOperation(options: IReserveLightFXSceneOperationOptions): Promise; + releaseSceneOperation(options: ILightFXSceneOperationToken): Promise; resolveTextureSource(options: IResolveLightFXTextureSourceOptions): Promise; begin(options: IBeginLightFXBakeOptions): Promise; appendInput(options: IAppendLightFXInputOptions): Promise; diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index 43804735b..7dca9c0d7 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -27,6 +27,8 @@ import type { IRunLightFXBakeOptions, IRunLightFXBakeResult, LightFXBakeTarget, + IReserveLightFXSceneOperationOptions, + ILightFXSceneOperationToken, } from '../common/lightfx-host'; import { assetManager } from '../../assets'; import { LightmapAssetTransaction } from './lightfx/asset-transaction'; @@ -74,6 +76,41 @@ const MAX_TEXTURE_SOURCES = 10_000; export class LightFXBakeHost implements ILightFXBakeHostService { private operation: LightFXHostOperation | null = null; private readonly completedOperations = new Map(); + private sceneOperation: (IReserveLightFXSceneOperationOptions & ILightFXSceneOperationToken & { nativeStarted: boolean; removingAssets: boolean }) | null = null; + private readonly releasedSceneOperations = new Set(); + + public async reserveSceneOperation(options: IReserveLightFXSceneOperationOptions): Promise { + if (!options || !['light-probe', 'lightmap'].includes(options.target) || !['bake', 'clear'].includes(options.action)) { + throw new Error('Invalid LightFX scene operation.'); + } + if (this.sceneOperation || this.operation) throw new Error('A LightFX scene transaction is already in progress on the host.'); + const transactionId = randomUUID(); + // No await before reservation. A lost renderer keeps this locked rather than admitting + // another writer while its old scene transaction might still resume. + this.sceneOperation = { target: options.target, action: options.action, transactionId, nativeStarted: false, removingAssets: false }; + return { transactionId }; + } + + public async releaseSceneOperation(options: ILightFXSceneOperationToken): Promise { + const id = options?.transactionId; + if (typeof id !== 'string' || !id) throw new Error('Invalid LightFX scene transaction id.'); + if (this.releasedSceneOperations.has(id)) return; + if (this.sceneOperation?.transactionId !== id) throw new Error('Unknown LightFX scene transaction.'); + if (this.operation || this.sceneOperation.removingAssets) throw new Error('LightFX host cleanup has not finished; scene transaction remains reserved.'); + this.sceneOperation = null; + this.releasedSceneOperations.add(id); + if (this.releasedSceneOperations.size > MAX_REMEMBERED_OPERATIONS) { + this.releasedSceneOperations.delete(this.releasedSceneOperations.values().next().value!); + } + } + + private validateSceneOperation(transactionId: string | undefined, target: LightFXBakeTarget, action: 'bake' | 'clear'): void { + if (!this.sceneOperation && transactionId === undefined) return; // Legacy native callers still reserve this.operation. + const current = this.sceneOperation; + if (!current || current.transactionId !== transactionId || current.target !== target || current.action !== action) { + throw new Error('LightFX scene transaction ownership does not match.'); + } + } public async resolveTextureSource( options: IResolveLightFXTextureSourceOptions, @@ -129,6 +166,8 @@ export class LightFXBakeHost implements ILightFXBakeHostService { throw new Error(`A ${this.operation.target} LightFX bake is already in progress.`); } this.validateBeginOptions(options); + this.validateSceneOperation(options.transactionId, options.target, 'bake'); + if (this.sceneOperation?.nativeStarted) throw new Error('This LightFX scene transaction has already started a bake.'); const assetRoot = this.queryAssetRoot(); const projectRoot = dirname(assetRoot); @@ -167,6 +206,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { // Reserve the global operation before the first asynchronous filesystem call. this.operation = operation; + if (this.sceneOperation) this.sceneOperation.nativeStarted = true; try { await ensureDir(tmpDir); await ensureDir(outputDir); @@ -325,9 +365,20 @@ export class LightFXBakeHost implements ILightFXBakeHostService { throw new Error(`A ${this.operation.target} LightFX bake is already in progress.`); } this.validateSceneName(options.sceneName); - const targetDir = join(this.queryAssetRoot(), options.sceneName, 'lightmap'); - await remove(targetDir); - await assetManager.refreshAsset(`db://assets/${options.sceneName}`); + this.validateSceneOperation(options.transactionId, 'lightmap', 'clear'); + const legacy = options.transactionId === undefined; + const token = legacy ? await this.reserveSceneOperation({ target: 'lightmap', action: 'clear' }) : { transactionId: options.transactionId! }; + const owner = this.sceneOperation!; + if (owner.removingAssets) throw new Error('Lightmap assets are already being removed.'); + owner.removingAssets = true; + try { + const targetDir = join(this.queryAssetRoot(), options.sceneName, 'lightmap'); + await remove(targetDir); + await assetManager.refreshAsset(`db://assets/${options.sceneName}`); + } finally { + owner.removingAssets = false; + if (legacy) await this.releaseSceneOperation(token); + } } /** Releases an abandoned operation when its owning Scene host shuts down. */ diff --git a/src/core/scene/scene-process/service/baking/lightfx/baker.ts b/src/core/scene/scene-process/service/baking/lightfx/baker.ts index 643abab41..7b6961312 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/baker.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/baker.ts @@ -3,6 +3,7 @@ import { encodeLightFXBase64 } from './buffer'; import { encodeLightFXInput } from './format'; import { LightFXExporter, LightFXExport } from './exporter'; import { lightFXBakeHost } from './host'; +import { lightFXSceneOperation } from './scene-operation'; import { LightFXBakeTarget, LightFXResult, LightFXSettings } from './types'; const INPUT_CHUNK_SIZE = 512 * 1024; @@ -25,6 +26,7 @@ class LightFXCoordinator { try { const exported = await new LightFXExporter().export(scene, target, settings); ({ operationId } = await lightFXBakeHost.begin({ + transactionId: lightFXSceneOperation.hostTransactionId, target, sceneName: scene.name, textureSources: exported.textureSources, @@ -63,7 +65,7 @@ class LightFXCoordinator { } removeLightmapAssets(sceneName: string): Promise { - return lightFXBakeHost.removeLightmapAssets({ sceneName }); + return lightFXBakeHost.removeLightmapAssets({ sceneName, transactionId: lightFXSceneOperation.hostTransactionId }); } async cancel(): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }> { diff --git a/src/core/scene/scene-process/service/baking/lightfx/host.ts b/src/core/scene/scene-process/service/baking/lightfx/host.ts index b460f2379..c1517b979 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/host.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/host.ts @@ -16,6 +16,8 @@ import { Rpc } from '../../../rpc'; /** JSON-only bridge from either a child scene process or a browser scene Webview to the Node host. */ export const lightFXBakeHost: ILightFXBakeHostService = { + reserveSceneOperation: (options) => Rpc.getInstance().request('lightFXBakeHost', 'reserveSceneOperation', [options]), + releaseSceneOperation: (options) => Rpc.getInstance().request('lightFXBakeHost', 'releaseSceneOperation', [options]), resolveTextureSource: (options: IResolveLightFXTextureSourceOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'resolveTextureSource', [options]), begin: (options: IBeginLightFXBakeOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'begin', [options]), appendInput: (options: IAppendLightFXInputOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'appendInput', [options]), diff --git a/src/core/scene/scene-process/service/baking/lightfx/scene-operation.ts b/src/core/scene/scene-process/service/baking/lightfx/scene-operation.ts index a1b87d7ec..af73ae181 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/scene-operation.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/scene-operation.ts @@ -1,8 +1,18 @@ import type { LightFXBakeTarget } from './types'; +import type { ILightFXBakeHostService } from '../../../../common/lightfx-host'; +import { lightFXBakeHost } from './host'; -/** Scene-local transaction guard. This does not replace the shared Node-host operation lease. */ +/** Local serialization plus a host reservation covering export through final scene rollback. */ export class LightFXSceneOperation { private active: { target: LightFXBakeTarget; action: 'bake' | 'clear' } | null = null; + private transactionId: string | undefined; + + constructor(private readonly host: Pick = lightFXBakeHost) {} + + get hostTransactionId(): string { + if (!this.transactionId) throw new Error('No LightFX scene transaction is reserved.'); + return this.transactionId; + } async run(target: LightFXBakeTarget, action: 'bake' | 'clear', operation: () => Promise): Promise { if (this.active) { @@ -13,8 +23,23 @@ export class LightFXSceneOperation { const owner = { target, action }; this.active = owner; try { - return await operation(); + const token = await this.host.reserveSceneOperation(owner); + this.transactionId = token.transactionId; + let result: T; + try { + result = await operation(); + } catch (error) { + // Preserve the scene failure if host cleanup must remain locked and retryable. + await this.host.releaseSceneOperation(token).catch((releaseError) => { + console.error('[LightFX] Scene reservation remains locked after failure:', releaseError); + }); + throw error; + } + // A failed release must not be reported as successful completion. + await this.host.releaseSceneOperation(token); + return result; } finally { + this.transactionId = undefined; if (this.active === owner) this.active = null; } } diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts index cb43fd161..d8414ae74 100644 --- a/src/core/scene/test/lightfx-bake-host.test.ts +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -56,11 +56,12 @@ describe('LightFXBakeHost', () => { await remove(root); }); - async function finishLightProbe(): Promise { + async function finishLightProbe(transactionId?: string): Promise { mockRunnerRun.mockImplementationOnce(async ({ cwd }: { cwd: string }) => { await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); }); const { operationId } = await host.begin({ + transactionId, target: 'light-probe', sceneName: 'LightProbe', textureSources: [], @@ -71,6 +72,76 @@ describe('LightFXBakeHost', () => { return operationId; } + it('reserves before export, rejects missing/wrong ownership and keeps the lease past native commit', async () => { + const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); + const opts = { target: 'light-probe' as const, sceneName: 'LightProbe', textureSources: [], timeoutMs: 120_000 }; + await expect(host.reserveSceneOperation({ target: 'lightmap', action: 'clear' })).rejects.toThrow('already in progress'); + await expect(host.begin(opts)).rejects.toThrow('ownership'); + await expect(host.begin({ ...opts, transactionId: 'other' })).rejects.toThrow('ownership'); + await expect(host.begin({ ...opts, ...token, target: 'lightmap' })).rejects.toThrow('ownership'); + await expect(host.releaseSceneOperation({ transactionId: 'other' })).rejects.toThrow('Unknown'); + const operationId = await finishLightProbe(token.transactionId); + await expect(host.releaseSceneOperation(token)).rejects.toThrow('cleanup has not finished'); + await host.commit({ operationId }); + await expect(host.reserveSceneOperation({ target: 'lightmap', action: 'clear' })).rejects.toThrow('already in progress'); + await expect(host.begin({ ...opts, ...token })).rejects.toThrow('already started'); + await host.releaseSceneOperation(token); + const next = await host.reserveSceneOperation({ target: 'lightmap', action: 'clear' }); + await host.releaseSceneOperation(token); // A repeated release cannot unlock next. + await expect(host.reserveSceneOperation({ target: 'light-probe', action: 'bake' })).rejects.toThrow('already in progress'); + await host.releaseSceneOperation(next); + await expect(host.begin({ ...opts, ...token })).rejects.toThrow('ownership'); + }); + + it('keeps a reservation after native rollback until scene recovery has finished', async () => { + const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); + const operationId = await finishLightProbe(token.transactionId); + await host.rollback({ operationId }); + await expect(host.reserveSceneOperation({ target: 'lightmap', action: 'clear' })).rejects.toThrow('already in progress'); + await host.releaseSceneOperation(token); + await expect(host.reserveSceneOperation({ target: 'lightmap', action: 'clear' })).resolves.toHaveProperty('transactionId'); + }); + + it('rejects invalid reservation and clear credentials without deleting assets', async () => { + await expect(host.reserveSceneOperation({ target: 'invalid' as any, action: 'clear' })).rejects.toThrow('Invalid'); + await expect(host.releaseSceneOperation({ transactionId: '' })).rejects.toThrow('Invalid'); + const file = join(assetRoot, 'Fixture', 'lightmap', 'owned.png'); + await outputFile(file, 'preserve'); + const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'clear' }); + await expect(host.removeLightmapAssets({ sceneName: 'Fixture', ...token })).rejects.toThrow('ownership'); + await expect(host.removeLightmapAssets({ sceneName: 'Fixture' })).rejects.toThrow('ownership'); + await expect(readFile(file, 'utf8')).resolves.toBe('preserve'); + await host.releaseSceneOperation(token); + }); + + it.each([false, true])('keeps deletion and asset refresh locked (legacy=%s)', async (legacy) => { + let finish!: () => void; + let entered!: () => void; + const enteredRefresh = new Promise(resolve => { entered = resolve; }); + mockAssetManager.refreshAsset.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; entered(); })); + const token = legacy ? undefined : await host.reserveSceneOperation({ target: 'lightmap', action: 'clear' }); + const removing = host.removeLightmapAssets({ sceneName: 'Fixture', ...token }); + await enteredRefresh; + await expect(host.reserveSceneOperation({ target: 'light-probe', action: 'bake' })).rejects.toThrow('already in progress'); + if (token) { + await expect(host.releaseSceneOperation(token)).rejects.toThrow('cleanup has not finished'); + await expect(host.removeLightmapAssets({ sceneName: 'Fixture', ...token })).rejects.toThrow('already being removed'); + } + finish(); await removing; + if (token) await host.releaseSceneOperation(token); + await expect(host.reserveSceneOperation({ target: 'light-probe', action: 'bake' })).resolves.toHaveProperty('transactionId'); + }); + + it('reserves against legacy native operations and keeps ownership after begin validation failure', async () => { + const operationId = await finishLightProbe(); + await expect(host.reserveSceneOperation({ target: 'lightmap', action: 'clear' })).rejects.toThrow('already in progress'); + await host.rollback({ operationId }); + const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); + await expect(host.begin({ ...token, target: 'light-probe', sceneName: 'Scene', textureSources: [], timeoutMs: 1 })).rejects.toThrow('timeout'); + await expect(host.reserveSceneOperation({ target: 'lightmap', action: 'clear' })).rejects.toThrow('already in progress'); + await host.releaseSceneOperation(token); + }); + it('accepts chunked input, reserves one operation, and rolls it back idempotently', async () => { const { operationId } = await host.begin({ target: 'light-probe', @@ -244,7 +315,9 @@ describe('LightFXBakeHost', () => { }); it('preserves a rollback backup and the active operation when restoration fails', async () => { + const token = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); const { operationId } = await host.begin({ + ...token, target: 'lightmap', sceneName: 'LightProbe', textureSources: [], @@ -260,12 +333,15 @@ describe('LightFXBakeHost', () => { await expect(pathExists(operation.workspace)).resolves.toBe(true); expect((host as any).completedOperations.has(operationId)).toBe(false); expect((host as any).operation).toBe(operation); + await expect(host.releaseSceneOperation(token)).rejects.toThrow('cleanup has not finished'); + await expect(host.reserveSceneOperation({ target: 'light-probe', action: 'clear' })).rejects.toThrow('already in progress'); await expect(host.rollback({ operationId })).resolves.toBeUndefined(); expect(rollbackAssets).toHaveBeenCalledTimes(2); await expect(pathExists(operation.workspace)).resolves.toBe(false); await expect(host.commit({ operationId })) .rejects.toThrow('LightFX bake was rolled-back and cannot be committed.'); + await host.releaseSceneOperation(token); }); it('marks an awaiting commit as expired before asynchronous cleanup starts', async () => { diff --git a/src/core/scene/test/lightfx-scene-entrances.test.ts b/src/core/scene/test/lightfx-scene-entrances.test.ts index fa612d763..a2fa4db00 100644 --- a/src/core/scene/test/lightfx-scene-entrances.test.ts +++ b/src/core/scene/test/lightfx-scene-entrances.test.ts @@ -1,7 +1,10 @@ const mockGetScene = jest.fn(); jest.mock('cc', () => ({ director: { getScene: mockGetScene } })); jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: {} })); -jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: {} })); +jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { + reserveSceneOperation: jest.fn(async () => ({ transactionId: 'test-owner' })), + releaseSceneOperation: jest.fn(async () => undefined), +} })); jest.mock('../scene-process/service/baking/lightfx/settings', () => ({ createDefaultLightFXSettings: jest.fn() })); jest.mock('../scene-process/service/preview/asset-reload', () => ({ loadPreviewAsset: jest.fn() })); jest.mock('../scene-process/rpc', () => ({ Rpc: { getInstance: jest.fn() } })); diff --git a/src/core/scene/test/lightfx-scene-operation.test.ts b/src/core/scene/test/lightfx-scene-operation.test.ts index 98c93f511..6c4e62515 100644 --- a/src/core/scene/test/lightfx-scene-operation.test.ts +++ b/src/core/scene/test/lightfx-scene-operation.test.ts @@ -1,9 +1,53 @@ import { LightFXSceneOperation } from '../scene-process/service/baking/lightfx/scene-operation'; +jest.mock('../scene-process/service/baking/lightfx/host', () => ({ + lightFXBakeHost: { + reserveSceneOperation: jest.fn(async () => ({ transactionId: 'test-owner' })), + releaseSceneOperation: jest.fn(async () => undefined), + }, +})); + const targets = ['light-probe', 'lightmap'] as const; const actions = ['bake', 'clear'] as const; describe('LightFX scene-local transactions', () => { + it('does not run scene code or release another owner when the host rejects reservation', async () => { + const host = { reserveSceneOperation: jest.fn(async () => { throw new Error('Host busy'); }), releaseSceneOperation: jest.fn() }; + const operation = jest.fn(); + await expect(new LightFXSceneOperation(host).run('light-probe', 'clear', operation)).rejects.toThrow('Host busy'); + expect(operation).not.toHaveBeenCalled(); + expect(host.releaseSceneOperation).not.toHaveBeenCalled(); + }); + + it('holds local ownership until the host release finishes and passes the exact token', async () => { + let release!: () => void; + const releasing = new Promise(resolve => { release = resolve; }); + const host = { reserveSceneOperation: jest.fn(async () => ({ transactionId: 'owner-A' })), releaseSceneOperation: jest.fn(() => releasing) }; + const guard = new LightFXSceneOperation(host); + const current = guard.run('light-probe', 'bake', async () => guard.hostTransactionId); + await Promise.resolve(); await Promise.resolve(); + await expect(guard.run('lightmap', 'clear', async () => 0)).rejects.toThrow('already in progress'); + expect(host.releaseSceneOperation).toHaveBeenCalledWith({ transactionId: 'owner-A' }); + release(); + await expect(current).resolves.toBe('owner-A'); + expect(() => guard.hostTransactionId).toThrow('No LightFX scene transaction'); + }); + + it('reports release failure without claiming successful completion', async () => { + const host = { reserveSceneOperation: jest.fn(async () => ({ transactionId: 'owner-A' })), releaseSceneOperation: jest.fn(async () => { throw new Error('Cleanup pending'); }) }; + await expect(new LightFXSceneOperation(host).run('lightmap', 'clear', async () => 1)).rejects.toThrow('Cleanup pending'); + }); + + it('preserves the scene failure when host release also fails', async () => { + const host = { reserveSceneOperation: jest.fn(async () => ({ transactionId: 'owner-A' })), releaseSceneOperation: jest.fn(async () => { throw new Error('Rollback pending'); }) }; + const errorLog = jest.spyOn(console, 'error').mockImplementation(() => undefined); + try { + await expect(new LightFXSceneOperation(host).run('light-probe', 'bake', async () => { throw new Error('Scene apply failed'); })).rejects.toThrow('Scene apply failed'); + expect(host.releaseSceneOperation).toHaveBeenCalledWith({ transactionId: 'owner-A' }); + expect(errorLog).toHaveBeenCalled(); + } finally { errorLog.mockRestore(); } + }); + for (const target of targets) for (const action of actions) { it(`${target} ${action} excludes all four entrances until the entire transaction settles`, async () => { const guard = new LightFXSceneOperation(); From 6a1f79ace0d0568499c40de0bcf0c7fd51a1e8de Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 12:25:00 +0800 Subject: [PATCH 13/64] =?UTF-8?q?feat(scene):=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E5=85=89=E7=85=A7=E6=8E=A2=E9=92=88=E7=83=98=E7=84=99=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E4=B8=8E=E5=AE=BF=E4=B8=BB=E5=BF=99=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 13 +++++++++++ src/core/scene/common/lightfx-bake.ts | 15 +++++++++++- src/core/scene/common/lightfx-host.ts | 7 ++++++ .../scene/main-process/lightfx-bake-host.ts | 5 ++++ .../main-process/lightfx-bake-renderer.ts | 2 +- .../main-process/proxy/lightfx-bake-proxy.ts | 4 ++++ .../scene/scene-process/engine-bootstrap.ts | 4 ++-- .../service/baking/lightfx/host.ts | 1 + .../scene-process/service/light-probe-bake.ts | 10 ++++++++ src/core/scene/test/lightfx-bake-host.test.ts | 19 +++++++++++++++ .../scene/test/lightfx-bake-renderer.test.ts | 19 +++++++++++++++ .../test/lightfx-scene-entrances.test.ts | 23 +++++++++++++++++++ 12 files changed, 118 insertions(+), 4 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 755df8436..1bd250c58 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -21,6 +21,19 @@ MCP API 只负责参数校验和结果封装。场景运行时负责导出场景 4. 同一 Scene host 下,Light Probe/Lightmap 的 Bake/Clear 共享事务预留;导出、结果应用、保存、Undo、失败恢复与可选资产清理期间拒绝新的冲突操作。 5. 在 Pink 中调用时,目标场景必须已在当前可见的场景视图中加载完成;不需要额外调用 `scene-open`。同时存在多个可见场景视图时,应先激活目标场景标签并关闭重复视图。 +## 运行能力识别 + +公开 CLI API `Scene.LightProbeBake.queryCapabilities()` 会向实际 Scene renderer(没有 Webview 时为 worker)及其 Node host 查询: + +```ts +const capabilities = await cli.Scene.LightProbeBake.queryCapabilities(); +// { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, busy: false } +``` + +`resultLifecycleVersion: 1` 表明本 Scene 实现包含 SH Undo/Redo 与多组重开结果保留修复;`sceneTransactionVersion: 1` 表明 Scene 与实际 host 均使用完整 Bake/Clear 事务预留协议。旧 host 缺少查询或协议不匹配时拒绝返回能力,调用方不能只检测 bake 方法存在或只检查包版本。集成方遇到方法缺失/查询失败应显示不支持或连接错误,不得自动尝试烘焙。 + +`busy` 仅为共享宿主的瞬时占用提示,包含导出前预留、原生操作、提交后场景回写及失败恢复;查询不占锁、不释放锁、不返回内部凭据。即使 busy=false,执行入口仍需原子预留,调用方必须处理查询之后发生的并发拒绝。该接口不检查原生 LightFX 可执行文件、场景输入合法性或渲染质量,也不是可恢复的任务状态/百分比/有归属取消接口。新旧 renderer 混用的限制仍见下文。 + ## MCP 工具 ### 并发与故障边界 diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts index 69035792b..339a55c30 100644 --- a/src/core/scene/common/lightfx-bake.ts +++ b/src/core/scene/common/lightfx-bake.ts @@ -13,6 +13,17 @@ export interface ILightProbeBakeOptions { timeoutMs?: number; } +/** Versioned implementation support, not native executable readiness or a recoverable task. */ +export interface ILightProbeBakeCapabilities { + version: 1; + /** SH Undo/Redo and multi-group scene reopening preserve baked results. */ + resultLifecycleVersion: 1; + /** Both Scene and host participate in the full Bake/Clear transaction reservation. */ + sceneTransactionVersion: 1; + /** Instantaneous shared host occupancy; execution still acquires its own reservation. */ + busy: boolean; +} + export interface ILightProbeBakeResult { sceneUrl: string; probeCount: number; @@ -73,6 +84,8 @@ export interface ILightFXBakeEvents { } export interface ILightProbeBakeService extends IServiceEvents { + /** Queries this Scene implementation and its actual host without modifying scene or task state. */ + queryCapabilities(): Promise; bake(options: ILightProbeBakeOptions): Promise; clearBake(options?: { saveScene?: boolean }): Promise<{ probeCount: number }>; cancel(): Promise; @@ -85,5 +98,5 @@ export interface ILightmapBakeService extends IServiceEvents { cancel(): Promise; } -export type IPublicLightProbeBakeService = Pick; +export type IPublicLightProbeBakeService = Pick; export type IPublicLightmapBakeService = Pick; diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts index 2800bb04b..d0c001965 100644 --- a/src/core/scene/common/lightfx-host.ts +++ b/src/core/scene/common/lightfx-host.ts @@ -11,6 +11,12 @@ export interface ILightFXSceneOperationToken { transactionId: string; } +/** Read-only host protocol snapshot. Busy is advisory, not permission to start a transaction. */ +export interface ILightFXHostCapabilities { + sceneTransactionVersion: 1; + busy: boolean; +} + /** JSON-safe reference to a texture needed by a LightFX input file. */ export interface ILightFXTextureSource { uuid: string; @@ -112,6 +118,7 @@ export interface IQueryLightmapTextureInfoResult { * return value in this contract must remain JSON serializable and must not expose host file paths. */ export interface ILightFXBakeHostService { + queryCapabilities(): Promise; reserveSceneOperation(options: IReserveLightFXSceneOperationOptions): Promise; releaseSceneOperation(options: ILightFXSceneOperationToken): Promise; resolveTextureSource(options: IResolveLightFXTextureSourceOptions): Promise; diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index 7dca9c0d7..c0f2a6d70 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -17,6 +17,7 @@ import type { IBeginLightFXBakeOptions, IBeginLightFXBakeResult, ILightFXBakeHostService, + ILightFXHostCapabilities, ILightFXOperationOptions, ILightFXTextureSource, IQueryLightmapTextureInfoOptions, @@ -79,6 +80,10 @@ export class LightFXBakeHost implements ILightFXBakeHostService { private sceneOperation: (IReserveLightFXSceneOperationOptions & ILightFXSceneOperationToken & { nativeStarted: boolean; removingAssets: boolean }) | null = null; private readonly releasedSceneOperations = new Set(); + public async queryCapabilities(): Promise { + return { sceneTransactionVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; + } + public async reserveSceneOperation(options: IReserveLightFXSceneOperationOptions): Promise { if (!options || !['light-probe', 'lightmap'].includes(options.target) || !['bake', 'clear'].includes(options.action)) { throw new Error('Invalid LightFX scene operation.'); diff --git a/src/core/scene/main-process/lightfx-bake-renderer.ts b/src/core/scene/main-process/lightfx-bake-renderer.ts index b3664c304..ca6c733ab 100644 --- a/src/core/scene/main-process/lightfx-bake-renderer.ts +++ b/src/core/scene/main-process/lightfx-bake-renderer.ts @@ -3,7 +3,7 @@ import type { DefaultEventsMap } from 'socket.io/dist/typed-events'; import { SCENE_RENDERER_ROOM, socketService } from '../../../server/socket'; type LightFXModule = 'LightProbeBake' | 'LightmapBake'; -type LightFXMethod = 'bake' | 'queryBakeInfo' | 'clearBake' | 'cancel'; +type LightFXMethod = 'bake' | 'queryBakeInfo' | 'queryCapabilities' | 'clearBake' | 'cancel'; interface RendererSocketData { sceneUrl?: string; diff --git a/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts b/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts index 2afd7573b..c26c41a61 100644 --- a/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts +++ b/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts @@ -3,6 +3,10 @@ import { lightFXBakeRenderer } from '../lightfx-bake-renderer'; import { Rpc } from '../rpc'; export const LightProbeBakeProxy: IPublicLightProbeBakeService = { + queryCapabilities: () => lightFXBakeRenderer.invoke( + 'LightProbeBake', 'queryCapabilities', [], 30_000, + () => Rpc.getInstance().request('LightProbeBake', 'queryCapabilities'), + ), bake: (options) => lightFXBakeRenderer.invoke( 'LightProbeBake', 'bake', [options], (options.timeoutMs ?? 600_000) + 30_000, () => Rpc.getInstance().request('LightProbeBake', 'bake', [options]), true, diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index 669085b21..913bc0b3e 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -281,14 +281,14 @@ async function setupBrowserInvokeChannel(serverURL: string) { msg: { sceneUrl?: string; module?: 'LightProbeBake' | 'LightmapBake'; - method?: 'bake' | 'queryBakeInfo' | 'clearBake' | 'cancel'; + method?: 'bake' | 'queryBakeInfo' | 'queryCapabilities' | 'clearBake' | 'cancel'; args?: unknown[]; }, reply: (response: { result?: unknown; sceneUrl?: string; error?: string }) => void, ) => { try { const methods = msg?.module === 'LightProbeBake' - ? new Set(['bake', 'clearBake', 'cancel']) + ? new Set(['bake', 'queryCapabilities', 'clearBake', 'cancel']) : msg?.module === 'LightmapBake' ? new Set(['bake', 'queryBakeInfo', 'clearBake', 'cancel']) : null; diff --git a/src/core/scene/scene-process/service/baking/lightfx/host.ts b/src/core/scene/scene-process/service/baking/lightfx/host.ts index c1517b979..149087320 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/host.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/host.ts @@ -16,6 +16,7 @@ import { Rpc } from '../../../rpc'; /** JSON-only bridge from either a child scene process or a browser scene Webview to the Node host. */ export const lightFXBakeHost: ILightFXBakeHostService = { + queryCapabilities: () => Rpc.getInstance().request('lightFXBakeHost', 'queryCapabilities'), reserveSceneOperation: (options) => Rpc.getInstance().request('lightFXBakeHost', 'reserveSceneOperation', [options]), releaseSceneOperation: (options) => Rpc.getInstance().request('lightFXBakeHost', 'releaseSceneOperation', [options]), resolveTextureSource: (options: IResolveLightFXTextureSourceOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'resolveTextureSource', [options]), diff --git a/src/core/scene/scene-process/service/light-probe-bake.ts b/src/core/scene/scene-process/service/light-probe-bake.ts index 80f5651ac..51c069dc6 100644 --- a/src/core/scene/scene-process/service/light-probe-bake.ts +++ b/src/core/scene/scene-process/service/light-probe-bake.ts @@ -3,12 +3,14 @@ import type { ILightFXBakeEvents, ILightFXCancelResult, ILightProbeBakeOptions, + ILightProbeBakeCapabilities, ILightProbeBakeResult, ILightProbeBakeService, } from '../../common'; import { lightFXCoordinator, LightFXBakeOutput } from './baking/lightfx/baker'; import { createDefaultLightFXSettings } from './baking/lightfx/settings'; import { lightFXSceneOperation } from './baking/lightfx/scene-operation'; +import { lightFXBakeHost } from './baking/lightfx/host'; import { BaseService, register, Service } from './core'; interface ProbeSnapshot { @@ -28,6 +30,14 @@ interface LightProbeSettings { @register('LightProbeBake') export class LightProbeBakeService extends BaseService implements ILightProbeBakeService { + async queryCapabilities(): Promise { + const host = await lightFXBakeHost.queryCapabilities(); + if (host?.sceneTransactionVersion !== 1 || typeof host.busy !== 'boolean') { + throw new Error('The LightFX host does not support scene transaction protocol version 1.'); + } + return { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, busy: host.busy }; + } + async bake(options: ILightProbeBakeOptions = {}): Promise { return lightFXSceneOperation.run('light-probe', 'bake', () => this.bakeExclusive(options)); } diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts index d8414ae74..dd9a727a2 100644 --- a/src/core/scene/test/lightfx-bake-host.test.ts +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -72,6 +72,25 @@ describe('LightFXBakeHost', () => { return operationId; } + it('queries protocol and occupancy without reserving, releasing or exposing ownership', async () => { + const idle = { sceneTransactionVersion: 1, busy: false }; + const busy = { ...idle, busy: true }; + await expect(host.queryCapabilities()).resolves.toEqual(idle); + const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); + await expect(host.queryCapabilities()).resolves.toEqual(busy); + await expect(host.queryCapabilities()).resolves.toEqual(busy); + const operationId = await finishLightProbe(token.transactionId); + await expect(host.queryCapabilities()).resolves.toEqual(busy); + await host.commit({ operationId }); + await expect(host.queryCapabilities()).resolves.toEqual(busy); + await host.releaseSceneOperation(token); + await expect(host.queryCapabilities()).resolves.toEqual(idle); + const legacyId = await finishLightProbe(); + await expect(host.queryCapabilities()).resolves.toEqual(busy); + await host.rollback({ operationId: legacyId }); + await expect(host.queryCapabilities()).resolves.toEqual(idle); + }); + it('reserves before export, rejects missing/wrong ownership and keeps the lease past native commit', async () => { const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); const opts = { target: 'light-probe' as const, sceneName: 'LightProbe', textureSources: [], timeoutMs: 120_000 }; diff --git a/src/core/scene/test/lightfx-bake-renderer.test.ts b/src/core/scene/test/lightfx-bake-renderer.test.ts index 3bb41e15b..fcdaa3ce6 100644 --- a/src/core/scene/test/lightfx-bake-renderer.test.ts +++ b/src/core/scene/test/lightfx-bake-renderer.test.ts @@ -80,6 +80,25 @@ describe('LightFX active scene renderer routing', () => { expect(fallback).toHaveBeenCalledTimes(1); }); + it('routes a probe capability query to the actual active renderer, with worker fallback only when absent', async () => { + const result = { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, busy: true }; + const visible = createSocket({ id: 'visible', sceneUrl: 'db://assets/Probe.scene', visible: true, result }); + useSockets([visible]); + const fallback = jest.fn(async () => result); + await expect(lightFXBakeRenderer.invoke( + 'LightProbeBake', 'queryCapabilities', [], 30_000, fallback, + )).resolves.toEqual(result); + expect(fallback).not.toHaveBeenCalled(); + expect(visible.emit).toHaveBeenCalledWith('scene:invoke-lightfx', expect.objectContaining({ + module: 'LightProbeBake', method: 'queryCapabilities', sceneUrl: 'db://assets/Probe.scene', + }), expect.any(Function)); + useSockets([]); + await expect(lightFXBakeRenderer.invoke( + 'LightProbeBake', 'queryCapabilities', [], 30_000, fallback, + )).resolves.toEqual(result); + expect(fallback).toHaveBeenCalledTimes(1); + }); + it('routes a lightmap bake-info query to the active renderer', async () => { const visible = createSocket({ id: 'visible', diff --git a/src/core/scene/test/lightfx-scene-entrances.test.ts b/src/core/scene/test/lightfx-scene-entrances.test.ts index a2fa4db00..5b411ed78 100644 --- a/src/core/scene/test/lightfx-scene-entrances.test.ts +++ b/src/core/scene/test/lightfx-scene-entrances.test.ts @@ -2,6 +2,7 @@ const mockGetScene = jest.fn(); jest.mock('cc', () => ({ director: { getScene: mockGetScene } })); jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: {} })); jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { + queryCapabilities: jest.fn(), reserveSceneOperation: jest.fn(async () => ({ transactionId: 'test-owner' })), releaseSceneOperation: jest.fn(async () => undefined), } })); @@ -12,8 +13,30 @@ jest.mock('../scene-process/rpc', () => ({ Rpc: { getInstance: jest.fn() } })); import { LightProbeBakeService } from '../scene-process/service/light-probe-bake'; import { LightmapBakeService } from '../scene-process/service/lightmap-bake'; import { lightFXSceneOperation } from '../scene-process/service/baking/lightfx/scene-operation'; +import { lightFXBakeHost } from '../scene-process/service/baking/lightfx/host'; describe('LightFX service entrance ownership', () => { + it.each([false, true])('queries the actual host without taking a reservation (busy=%s)', async (busy) => { + const query = jest.mocked(lightFXBakeHost.queryCapabilities); + query.mockResolvedValueOnce({ sceneTransactionVersion: 1, busy }); + const reserveCalls = jest.mocked(lightFXBakeHost.reserveSceneOperation).mock.calls.length; + await expect(new LightProbeBakeService().queryCapabilities()).resolves.toEqual({ + version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, busy, + }); + expect(lightFXBakeHost.reserveSceneOperation).toHaveBeenCalledTimes(reserveCalls); + expect(mockGetScene).not.toHaveBeenCalled(); + }); + + it('does not advertise support when the host query fails or returns an incompatible protocol', async () => { + const query = jest.mocked(lightFXBakeHost.queryCapabilities); + query.mockRejectedValueOnce(new Error('Method queryCapabilities is not available')); + await expect(new LightProbeBakeService().queryCapabilities()).rejects.toThrow('not available'); + for (const value of [null, {}, { sceneTransactionVersion: 2, busy: false }, { sceneTransactionVersion: 1 }]) { + query.mockResolvedValueOnce(value as Awaited>); + await expect(new LightProbeBakeService().queryCapabilities()).rejects.toThrow('protocol version 1'); + } + }); + it('rejects all four entrances before querying scene, snapshotting or rolling back another owner', async () => { const probe = new LightProbeBakeService(); const lightmap = new LightmapBakeService(); From fd27afa007a66e66b96ab3daa9ba2dfd66a1d662 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 14:34:09 +0800 Subject: [PATCH 14/64] =?UTF-8?q?fix(scene):=20=E5=90=8C=E6=AD=A5=E5=85=89?= =?UTF-8?q?=E7=85=A7=E6=8E=A2=E9=92=88=E7=BB=84=E4=BD=8D=E7=A7=BB=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=92=A4=E9=94=80=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 4 + .../scene/scene-process/service/node/index.ts | 2 + .../scene-process/service/node/node-undo.ts | 5 +- .../service/scene/light-probe-transform.ts | 36 ++++++++ src/core/scene/scene-process/service/undo.ts | 12 ++- .../scene/test/light-probe-transform.test.ts | 82 +++++++++++++++++++ 6 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 src/core/scene/scene-process/service/scene/light-probe-transform.ts create mode 100644 src/core/scene/test/light-probe-transform.test.ts diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 1bd250c58..37fc26cd3 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -80,6 +80,10 @@ Scene runtime 先通过内部 `reserveSceneOperation` 取得宿主生成的事 所有参数均可选,未传入时使用场景当前值。`giScale`、`giSamples` 和 `bounces` 参与 LightFX 计算;`reduceRinging`、`showWireframe`、`showConvex` 和 `lightProbeSphereVolume` 用于烘焙结果后处理或编辑器显示。烘焙成功后,本次的有效参数与 SH 结果作为同一次 Undo 操作写回 `LightProbeInfo`;烘焙失败或取消时保留原场景配置。 +编辑已启用探针组或其父节点的位置时,CLI 会同步全局采样点和四面体。只有实际采样位置改变才清空旧 SH,避免把旧位置的烘焙结果用于新位置;不会重新生成组件内手工编辑过的采样点。普通节点属性操作和 Gizmo recording 会把受影响的 Scene 数据纳入同一次撤销记录:Undo 恢复旧位置与旧 SH,Redo 恢复新位置与失效状态。保存仍由调用方决定,移动后需要重新烘焙。 + +此同步沿用当前引擎的 `localProbe + worldPosition` 约定;完整旋转/缩放与 Gizmo 的 TRS 一致性、重设父级和增删采样点的结构事务仍需独立验收,不等同于所有探针编辑操作已完成。 + 成功返回示例: ```json diff --git a/src/core/scene/scene-process/service/node/index.ts b/src/core/scene/scene-process/service/node/index.ts index b74174f4a..9315f98c6 100644 --- a/src/core/scene/scene-process/service/node/index.ts +++ b/src/core/scene/scene-process/service/node/index.ts @@ -47,6 +47,7 @@ import { type IScene } from '../../../common/editor/scene'; import { loadAny } from './node-create'; import compMgr from '../component/index'; import { Rpc } from '../../rpc'; +import { synchronizeLightProbeTransform } from '../scene/light-probe-transform'; const creatableAssetTypes = [ 'cc.AnimationClip', @@ -219,6 +220,7 @@ export class NodeManager { } onNodeTransformChanged(node: Node, transformBit: any) { + synchronizeLightProbeTransform(node); const changeOpts: IChangeNodeOptions = { type: NodeEventType.TRANSFORM_CHANGED, source: EventSourceType.ENGINE }; switch (transformBit) { diff --git a/src/core/scene/scene-process/service/node/node-undo.ts b/src/core/scene/scene-process/service/node/node-undo.ts index 16dec47b6..f3fd2c5a5 100644 --- a/src/core/scene/scene-process/service/node/node-undo.ts +++ b/src/core/scene/scene-process/service/node/node-undo.ts @@ -8,6 +8,7 @@ import { SnapshotCommand, type ISnapshotAdapter } from '../undo/commands/snapsho import type { INodeStructureCaptureTarget } from '../undo/commands/node-structure-command-utils'; import { createUndoId, restoreNodeSnapshotDump, snapshotMapsEqual } from '../undo/commands/command-utils-shared'; import { isRootNodePath } from '../../../../engine/editor-extends/manager/path-utils'; +import { withLightProbeTransformScenes } from '../scene/light-probe-transform'; const NodeMgr = EditorExtends.Node; @@ -91,7 +92,7 @@ export class NodeUndoHelper { return mutate(); } - const before = this.captureNodeSnapshots([node]); + const before = this.captureNodeSnapshots(withLightProbeTransformScenes([node])); const result = await mutate(); if (!result) { return result; @@ -102,7 +103,7 @@ export class NodeUndoHelper { return result; } - const after = this.captureNodeSnapshots([latestNode]); + const after = this.captureNodeSnapshots(this.findSnapshotNodes(before)); this.pushNodeSnapshotCommand(options.type, options.label, before, after, options.scope); return result; } diff --git a/src/core/scene/scene-process/service/scene/light-probe-transform.ts b/src/core/scene/scene-process/service/scene/light-probe-transform.ts new file mode 100644 index 000000000..bb2176929 --- /dev/null +++ b/src/core/scene/scene-process/service/scene/light-probe-transform.ts @@ -0,0 +1,36 @@ +import { Vec3, type Node, type Scene } from 'cc'; + +/** Finds the scene whose registered probe positions can be affected by this subtree. */ +export function getLightProbeTransformScene(node: Node): Scene | undefined { + const scene = node.scene; + if (!node.isValid || !scene?.globals?.lightProbeInfo) return; + const groups = node.getComponentsInChildren('cc.LightProbeGroup'); + return groups.some(group => group.isValid && group.enabledInHierarchy) ? scene : undefined; +} + +/** Keeps the engine's world-position probe convention current after a node transform. */ +export function synchronizeLightProbeTransform(node: Node): void { + const scene = getLightProbeTransformScene(node); + if (!scene) return; + const info = scene.globals.lightProbeInfo; + const before = (info.data?.probes ?? []).map(probe => Vec3.clone(probe.position)); + // The engine knows the registration order and active groups. Do not regenerate + // local sample points, reorder groups or introduce a second transform convention. + info.update(false); + const after = info.data?.probes ?? []; + if (before.length === after.length && before.every((point, index) => Vec3.strictEquals(point, after[index].position))) return; + info.update(true); + // A changed sample position cannot retain SH baked at the previous location. + info.onProbeBakeCleared(); +} + +/** Adds affected scene snapshots last so Undo restores node poses before probe data. */ +export function withLightProbeTransformScenes(nodes: Node[]): Node[] { + const result = new Set(nodes); + const scenes = new Set(nodes.map(getLightProbeTransformScene).filter((scene): scene is Scene => !!scene)); + for (const scene of scenes) { + result.delete(scene); + result.add(scene); + } + return [...result]; +} diff --git a/src/core/scene/scene-process/service/undo.ts b/src/core/scene/scene-process/service/undo.ts index 3c9815d9b..b89a9f981 100644 --- a/src/core/scene/scene-process/service/undo.ts +++ b/src/core/scene/scene-process/service/undo.ts @@ -7,6 +7,7 @@ import { ServiceEvents } from './core/global-events'; import type { ISnapshotAdapter } from './undo/commands/snapshot-command'; import { restoreComponentSnapshotDump, restoreNodeSnapshotDump, snapshotMapsEqual } from './undo/commands/command-utils-shared'; import dumpUtil from './dump'; +import { withLightProbeTransformScenes } from './scene/light-probe-transform'; interface IRecordingComponentSnapshot { uuid: string; @@ -44,7 +45,16 @@ export class UndoService extends BaseService implements IUndoServic } beginRecording(uuids: string[], options?: IUndoBeginOptions): string { - return this._undoMgr.beginRecording(uuids, options); + const nodes = uuids.map(uuid => { + const node = this._getEditorNodeManager()?.getNode?.(uuid) as Node | undefined; + return node ?? (this._getEditorComponentManager()?.getComponent?.(uuid) as Component | undefined)?.node; + }).filter((node): node is Node => this._isNodeInCurrentScene(node)); + // Fix the target set before the mutation. Even if a component is disabled + // while recording, before/after must capture the same scene globals. + const scenes = withLightProbeTransformScenes(nodes).filter(node => node === node.scene); + const targets = new Set(uuids); + for (const scene of scenes) { targets.delete(scene.uuid); targets.add(scene.uuid); } + return this._undoMgr.beginRecording([...targets], options); } async endRecording(commandId: string): Promise { diff --git a/src/core/scene/test/light-probe-transform.test.ts b/src/core/scene/test/light-probe-transform.test.ts new file mode 100644 index 000000000..dc010716b --- /dev/null +++ b/src/core/scene/test/light-probe-transform.test.ts @@ -0,0 +1,82 @@ +jest.mock('cc', () => ({ + Vec3: class Vec3 { + constructor(public x = 0, public y = 0, public z = 0) {} + static clone(v: { x: number; y: number; z: number }) { return new this(v.x, v.y, v.z); } + static strictEquals(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }) { + return a.x === b.x && a.y === b.y && a.z === b.z; + } + }, +})); + +import { Vec3, type Node, type Scene } from 'cc'; +import { getLightProbeTransformScene, synchronizeLightProbeTransform, withLightProbeTransformScenes } from '../scene-process/service/scene/light-probe-transform'; + +function fixture() { + const group = { isValid: true, enabledInHierarchy: true }; + const nextPositions = Array.from({ length: 4 }, (_, index) => new Vec3(index, 0, 0)); + const probes = nextPositions.map(position => ({ position: Vec3.clone(position), coefficients: [new Vec3(1, 2, 3)] })); + const events: string[] = []; + const info = { + data: { probes }, + update: jest.fn((tet: boolean) => { + events.push(tet ? 'tetrahedrons' : 'positions'); + // Model the engine's in-place mutation and retention of stale SH. + probes.forEach((probe, i) => Object.assign(probe.position, nextPositions[i])); + }), + onProbeBakeCleared: jest.fn(() => { events.push('clear'); probes.forEach(probe => { probe.coefficients = []; }); }), + }; + const scene = { isValid: true, globals: { lightProbeInfo: info }, getComponentsInChildren: () => [group] } as unknown as Scene; + Object.defineProperty(scene, 'scene', { value: scene }); + const node = { isValid: true, scene, getComponentsInChildren: () => [group] } as unknown as Node; + return { scene, node, group, nextPositions, info, events }; +} + +describe('Light probe position synchronization', () => { + it('updates positions, rebuilds once and invalidates SH only when samples actually moved', () => { + const { node, nextPositions, info, events } = fixture(); + nextPositions.forEach(point => { point.x += 7; }); + synchronizeLightProbeTransform(node); + expect({ events, positions: info.data.probes.map(probe => probe.position), coefficients: info.data.probes.map(probe => probe.coefficients) }) + .toEqual({ events: ['positions', 'tetrahedrons', 'clear'], positions: nextPositions, coefficients: [[], [], [], []] }); + synchronizeLightProbeTransform(node); + expect(info.onProbeBakeCleared).toHaveBeenCalledTimes(1); + expect(info.update.mock.calls).toEqual([[false], [true], [false]]); + }); + + it('preserves baked coefficients and tetrahedrons for unchanged sample positions', () => { + const { node, info } = fixture(); + const before = JSON.stringify(info.data); + synchronizeLightProbeTransform(node); + expect(JSON.stringify(info.data)).toBe(before); + expect(info.onProbeBakeCleared).not.toHaveBeenCalled(); + expect(info.update.mock.calls).toEqual([[false]]); + }); + + it.each(['disabled', 'destroyed', 'unrelated'] as const)('does not rebuild a %s subtree', kind => { + const { node, group, info } = fixture(); + if (kind === 'disabled') group.enabledInHierarchy = false; + if (kind === 'destroyed') group.isValid = false; + if (kind === 'unrelated') node.getComponentsInChildren = (() => []) as Node['getComponentsInChildren']; + synchronizeLightProbeTransform(node); + expect(info.update).not.toHaveBeenCalled(); + }); + + it('handles ancestor transforms through descendants without relying on selection', () => { + const { scene, group } = fixture(); + const parent = { isValid: true, scene, getComponentsInChildren: () => [group] } as unknown as Node; + expect(getLightProbeTransformScene(parent)).toBe(scene); + }); + + it('captures one scene after all affected nodes, even when explicitly selected first', () => { + const { scene, node } = fixture(); + const other = { isValid: true, scene, getComponentsInChildren: () => [] } as unknown as Node; + expect(withLightProbeTransformScenes([scene, node, other, node])).toEqual([node, other, scene]); + expect(withLightProbeTransformScenes([other])).toEqual([other]); + }); + + it('ignores detached or invalid nodes without a scene', () => { + const nodes = [{ isValid: true }, { isValid: false }] as Node[]; + expect(withLightProbeTransformScenes(nodes)).toEqual(nodes); + nodes.forEach(synchronizeLightProbeTransform); + }); +}); From 2c05f8773b3ac1ef0d94a035c6646cab029c41ed Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 14:39:51 +0800 Subject: [PATCH 15/64] =?UTF-8?q?fix(scene):=20=E4=BF=AE=E6=AD=A3=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E7=83=98=E7=84=99=E4=B8=8E=E6=B8=85=E7=90=86=E7=9A=84?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E4=BF=9D=E5=AD=98=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 2 + .../service/baking/lightfx/saved-recording.ts | 21 ++++++ .../scene-process/service/light-probe-bake.ts | 11 +-- .../test/lightfx-saved-recording.test.ts | 73 +++++++++++++++++++ 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 src/core/scene/scene-process/service/baking/lightfx/saved-recording.ts create mode 100644 src/core/scene/test/lightfx-saved-recording.test.ts diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 37fc26cd3..c1338459f 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -120,6 +120,8 @@ Scene runtime 先通过内部 `reserveSceneOperation` 取得宿主生成的事 该操作清除当前场景全部探针的烘焙结果,通知引擎刷新,并作为一次 Undo 操作记录。成功结果中的 `probeCount` 表示处理的探针数量。 +Probe Bake/Clear 的 `saveScene` 默认是 `true`:完整成功后,当前结果作为 Undo 的已保存基线;Undo 回到旧结果会变脏,Redo 回到已保存结果恢复干净。显式传 `false` 时只修改内存并保留 dirty,调用方需要另行保存。保存失败不提交新录制;原生提交失败、提交期间发生其他编辑或历史重置时,不额外把当前历史标成已保存。这不代表跨磁盘与原生资产提交的失败回滚已经具备完整原子性。 + ### 烘焙 Lightmap 工具名:`scene-bake-lightmap` diff --git a/src/core/scene/scene-process/service/baking/lightfx/saved-recording.ts b/src/core/scene/scene-process/service/baking/lightfx/saved-recording.ts new file mode 100644 index 000000000..554456a7f --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/saved-recording.ts @@ -0,0 +1,21 @@ +import type { IUndoService } from '../../../../common'; + +/** Completes a result recording without treating a later edit as part of its saved result. */ +export async function finishSavedLightFXRecording( + undo: Pick, + recordingId: string, + save?: () => Promise, + commit?: () => Promise, +): Promise { + if (save) await save(); + const saved = save ? undo.createCheckpoint() : undefined; + await undo.endRecording(recordingId); + if (commit) await commit(); + if (!saved) return; + const current = undo.createCheckpoint(); + // Editor.save marks the previous history entry because recording is still + // open. Advance that mark only for this exact committed recording. A no-op + // recording needs no new mark; edits or history resets during commit do not + // belong to the saved result and must remain dirty. + if (current.commandId === recordingId && current.generation === saved.generation) undo.markSaved(); +} diff --git a/src/core/scene/scene-process/service/light-probe-bake.ts b/src/core/scene/scene-process/service/light-probe-bake.ts index 51c069dc6..0c54b0490 100644 --- a/src/core/scene/scene-process/service/light-probe-bake.ts +++ b/src/core/scene/scene-process/service/light-probe-bake.ts @@ -11,6 +11,7 @@ import { lightFXCoordinator, LightFXBakeOutput } from './baking/lightfx/baker'; import { createDefaultLightFXSettings } from './baking/lightfx/settings'; import { lightFXSceneOperation } from './baking/lightfx/scene-operation'; import { lightFXBakeHost } from './baking/lightfx/host'; +import { finishSavedLightFXRecording } from './baking/lightfx/saved-recording'; import { BaseService, register, Service } from './core'; interface ProbeSnapshot { @@ -80,9 +81,9 @@ export class LightProbeBakeService extends BaseService imple this.applyResult(probes, output); info.onProbeBakeFinished(); await Service.Engine.repaintInEditMode(); - if (options.saveScene !== false) await Service.Editor.save({}); - await Service.Undo.endRecording(undo); - await lightFXCoordinator.commit(output.operationId); + await finishSavedLightFXRecording(Service.Undo, undo, + options.saveScene !== false ? () => Service.Editor.save({}) : undefined, + () => lightFXCoordinator.commit(output!.operationId)); } catch (error) { Service.Undo.cancelRecording(undo); throw error; @@ -120,8 +121,8 @@ export class LightProbeBakeService extends BaseService imple try { info.onProbeBakeCleared(); await Service.Engine.repaintInEditMode(); - if (options.saveScene !== false) await Service.Editor.save({}); - await Service.Undo.endRecording(undo); + await finishSavedLightFXRecording(Service.Undo, undo, + options.saveScene !== false ? () => Service.Editor.save({}) : undefined); return { probeCount: probes.length }; } catch (error) { Service.Undo.cancelRecording(undo); diff --git a/src/core/scene/test/lightfx-saved-recording.test.ts b/src/core/scene/test/lightfx-saved-recording.test.ts new file mode 100644 index 000000000..38d10a92b --- /dev/null +++ b/src/core/scene/test/lightfx-saved-recording.test.ts @@ -0,0 +1,73 @@ +import { SceneUndoManager } from '../scene-process/service/undo/scene-undo-manager'; +import { finishSavedLightFXRecording } from '../scene-process/service/baking/lightfx/saved-recording'; + +function fixture() { + let data = 'old SH'; + let disk = data; + const events: string[] = []; + const manager = new SceneUndoManager({ snapshotAdapter: { + capture: () => new Map([['scene', data]]), + apply: snapshot => { data = snapshot.get('scene'); return { success: true }; }, + equals: (before, after) => before.get('scene') === after.get('scene'), + } }); + const undo = { + endRecording: async (id: string) => { events.push('record'); await manager.endRecording(id); }, + createCheckpoint: () => manager.createCheckpoint(), + markSaved: jest.fn(() => { events.push('mark'); manager.markSaved(); }), + }; + const save = async () => { events.push('save'); disk = data; manager.markSaved(); }; + const record = (value: string) => { const id = manager.beginRecording(['scene']); data = value; return id; }; + return { manager, undo, save, record, events, read: () => ({ data, disk, dirty: manager.isDirty() }) }; +} + +describe('LightFX result save baseline', () => { + it.each(['new baked SH', ''])('marks the completed saved result, Undo becomes dirty and Redo returns to saved (%s)', async value => { + const f = fixture(); + const id = f.record(value); + await finishSavedLightFXRecording(f.undo, id, f.save, async () => { f.events.push('commit'); }); + expect({ ...f.read(), events: f.events }).toEqual({ data: value, disk: value, dirty: false, events: ['save', 'record', 'commit', 'mark'] }); + await f.manager.undo(); + expect(f.read()).toEqual({ data: 'old SH', disk: value, dirty: true }); + await f.manager.redo(); + expect(f.read()).toEqual({ data: value, disk: value, dirty: false }); + }); + + it('leaves an explicitly unsaved result dirty and does not write disk', async () => { + const f = fixture(); + await finishSavedLightFXRecording(f.undo, f.record('new SH')); + expect({ ...f.read(), events: f.events }).toEqual({ data: 'new SH', disk: 'old SH', dirty: true, events: ['record'] }); + }); + + it('does not commit a recording if saving fails', async () => { + const f = fixture(); + const id = f.record('new SH'); + await expect(finishSavedLightFXRecording(f.undo, id, async () => { throw new Error('save failed'); })).rejects.toThrow('save failed'); + expect(f.events).toEqual([]); + expect(f.manager.canUndo()).toBe(false); + f.manager.cancelRecording(id); + }); + + it('does not mark a failed native commit as saved', async () => { + const f = fixture(); + await expect(finishSavedLightFXRecording(f.undo, f.record('new SH'), f.save, async () => { throw new Error('commit failed'); })).rejects.toThrow('commit failed'); + expect(f.undo.markSaved).not.toHaveBeenCalled(); + expect(f.manager.isDirty()).toBe(true); + }); + + it('does not mark an edit made during the asynchronous native commit as saved', async () => { + const f = fixture(); + await finishSavedLightFXRecording(f.undo, f.record('new SH'), f.save, async () => { + await f.manager.endRecording(f.record('later edit')); + }); + expect(f.undo.markSaved).not.toHaveBeenCalled(); + expect(f.read()).toEqual({ data: 'later edit', disk: 'new SH', dirty: true }); + }); + + it('does not remark no-op recordings or a reset history', async () => { + const f = fixture(); + await finishSavedLightFXRecording(f.undo, f.record('old SH'), f.save); + expect(f.undo.markSaved).not.toHaveBeenCalled(); + await finishSavedLightFXRecording(f.undo, f.record('new SH'), f.save, async () => { f.manager.reset(); }); + expect(f.undo.markSaved).not.toHaveBeenCalled(); + }); +}); From e05d4bbc3a2f63cd064eada60aaef605ebc1686a Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 14:47:04 +0800 Subject: [PATCH 16/64] =?UTF-8?q?fix(scene):=20=E8=A1=A5=E9=BD=90=E5=85=89?= =?UTF-8?q?=E7=85=A7=E8=B4=B4=E5=9B=BE=E7=BB=93=E6=9E=9C=E7=9A=84=E6=92=A4?= =?UTF-8?q?=E9=94=80=E7=9B=AE=E6=A0=87=E4=B8=8E=E7=A9=BA=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 6 +- .../scene-process/service/dump/encode.ts | 2 + .../service/dump/lightmap-metadata.ts | 12 +++ .../scene-process/service/lightmap-bake.ts | 19 +++-- src/core/scene/test/lightmap-metadata.test.ts | 19 +++++ .../test/lightmap-result-recording.test.ts | 85 +++++++++++++++++++ 6 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 src/core/scene/scene-process/service/dump/lightmap-metadata.ts create mode 100644 src/core/scene/test/lightmap-metadata.test.ts create mode 100644 src/core/scene/test/lightmap-result-recording.test.ts diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index c1338459f..6e06ff59a 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -333,7 +333,9 @@ LightFX 当前可能输出 Creator 历史协议版本。解析器只接受已知 - 已有另一个 LightFX 任务运行。 - 当前可见场景尚未加载完成,或同时存在多个可见的场景渲染器。 -Bake 和 Clear 都记录为单次 Undo 操作。场景结果提交失败时恢复原组件数据和全局标记;Lightmap 资产提交失败时还会恢复原 PNG 与 `.meta`。 +Bake 和 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与结果修改的 MeshRenderer/Terrain 组件(同一 Terrain 多 block 去重)及 Scene 标记,不能只录制不递归的 Scene 根节点;旧引擎缺类型的空纹理引用也会保留在快照中。成功自动保存后以该记录作为保存点;saveScene:false 不隐式保存场景。场景结果提交失败时尝试恢复原组件数据和全局标记;Lightmap 资产提交失败时还会恢复原 PNG 与 `.meta`。 + +注意绑定历史与资产版本是两件事:以上 Undo 恢复纹理引用、UV 和场景标记,不承诺重复 Bake 覆盖同一 PNG 后能恢复上一版像素。`deleteAssets:true` 还涉及目录删除,不属于可恢复绑定的保留资产验证范围,调用方不可据此假定删除可撤销。 ## 验证范围 @@ -348,3 +350,5 @@ Bake 和 Clear 都记录为单次 Undo 操作。场景结果提交失败时恢 - TypeScript 编译、ESLint、API、协议和资产事务测试。 新增材质类型、灯光类型、LightFX 版本或目标平台时,应补充对应真实场景回归。 + +2026-09-10 结果历史专项:macOS arm64/隔离 PinK,真实带第二套 UV 的 Mesh 烘焙 128px 标准/高精度贴图;Bake、保留资产的 Clear、独立 Undo/Redo、渲染模型 UV、显式/自动保存、真正关闭重开通过,旁侧 43 点探针全部 SH 保持。Terrain 多 block 录制目标及失败恢复由服务测试覆盖,未在本次专项重做 Terrain 原生场景实测;也没有验收旧 PNG 像素版本撤销、资产删除撤销或最终画面质量。 diff --git a/src/core/scene/scene-process/service/dump/encode.ts b/src/core/scene/scene-process/service/dump/encode.ts index 1371371a1..64cda0cca 100644 --- a/src/core/scene/scene-process/service/dump/encode.ts +++ b/src/core/scene/scene-process/service/dump/encode.ts @@ -7,6 +7,7 @@ import dumpUtil from './utils'; import { getDumpComponentAccess } from './service-access'; import { applyParticleInspectorMetadata } from './particle-inspector-metadata'; import { withLightProbeCoefficientType } from './light-probe-metadata'; +import { withLightmapTextureType } from './lightmap-metadata'; import { DumpDefines } from './dump-defines'; import { IProperty } from '../../../@types/public'; @@ -622,6 +623,7 @@ function _checkObjFlags(node: any, data: INode) { */ export function encodeObject(object: any, attributes: any, owner: any = null, objectKey?: string, isTemplate?: boolean): IProperty { attributes = withLightProbeCoefficientType(attributes, owner, objectKey); + attributes = withLightmapTextureType(attributes, owner, objectKey); const ctor = dumpUtil.getConstructor(object, attributes); let defValue = dumpUtil.getDefault(attributes); diff --git a/src/core/scene/scene-process/service/dump/lightmap-metadata.ts b/src/core/scene/scene-process/service/dump/lightmap-metadata.ts new file mode 100644 index 000000000..3d8ed0dc4 --- /dev/null +++ b/src/core/scene/scene-process/service/dump/lightmap-metadata.ts @@ -0,0 +1,12 @@ +import { js, Texture2D } from 'cc'; + +/** Keep cleared lightmap bindings in snapshots even on engines without asset type metadata. */ +export function withLightmapTextureType(attributes: T, owner: object | null, key?: string): T | (T & { ctor: typeof Texture2D }) { + if ((!('ctor' in attributes) || !attributes.ctor) && owner && key === 'texture') { + const type = js.getClassName(owner); + if (type === 'cc.ModelBakeSettings' || type === 'cc.TerrainBlockLightmapInfo') { + return { ...attributes, ctor: Texture2D }; + } + } + return attributes; +} diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index bc2d3fd4e..d30deba00 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -9,6 +9,7 @@ import type { LightFXBakeOutput } from './baking/lightfx/baker'; import { lightFXBakeHost } from './baking/lightfx/host'; import { createDefaultLightFXSettings } from './baking/lightfx/settings'; import { lightFXSceneOperation } from './baking/lightfx/scene-operation'; +import { finishSavedLightFXRecording } from './baking/lightfx/saved-recording'; import { BaseService, register, Service } from './core'; import { loadPreviewAsset } from './preview/asset-reload'; @@ -61,15 +62,18 @@ export class LightmapBakeService extends BaseService impleme const previousBindings = this.snapshotBindings(output); const previousHighp = (scene.globals as any).bakedWithHighpLightmap; const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; - const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Bake lightmap' }); + // Scene recordings do not recursively capture child components. + // Keep the flags last, after restoring each affected result binding. + const targets = [...new Set([...output.models, ...output.terrains].map(component => component.uuid)), scene.uuid]; + const undo = Service.Undo.beginRecording(targets, { label: 'Bake lightmap' }); try { this.applyBakeResult(output, textures); (scene.globals as any).bakedWithHighpLightmap = settings.highp; (scene.globals as any).bakedWithStationaryMainLight = output.stationaryMainLight; await Service.Engine.repaintInEditMode(); - if (options.saveScene !== false) await Service.Editor.save({}); - await Service.Undo.endRecording(undo); - await lightFXCoordinator.commit(output.operationId); + await finishSavedLightFXRecording(Service.Undo, undo, + options.saveScene !== false ? () => Service.Editor.save({}) : undefined, + () => lightFXCoordinator.commit(output!.operationId)); } catch (error) { this.restoreBindings(previousBindings); (scene.globals as any).bakedWithHighpLightmap = previousHighp; @@ -148,14 +152,15 @@ export class LightmapBakeService extends BaseService impleme const bindings = this.snapshotSceneBindings(scene); const previousHighp = (scene.globals as any).bakedWithHighpLightmap; const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; - const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Clear lightmap' }); + const targets = [...new Set(bindings.map(binding => binding.target.uuid as string)), scene.uuid]; + const undo = Service.Undo.beginRecording(targets, { label: 'Clear lightmap' }); try { this.clearBindings(bindings); (scene.globals as any).bakedWithHighpLightmap = false; (scene.globals as any).bakedWithStationaryMainLight = false; await Service.Engine.repaintInEditMode(); - if (options.saveScene !== false) await Service.Editor.save({}); - await Service.Undo.endRecording(undo); + await finishSavedLightFXRecording(Service.Undo, undo, + options.saveScene !== false ? () => Service.Editor.save({}) : undefined); } catch (error) { Service.Undo.cancelRecording(undo); this.restoreBindings(bindings); diff --git a/src/core/scene/test/lightmap-metadata.test.ts b/src/core/scene/test/lightmap-metadata.test.ts new file mode 100644 index 000000000..1461c3390 --- /dev/null +++ b/src/core/scene/test/lightmap-metadata.test.ts @@ -0,0 +1,19 @@ +const mockTexture = class Texture2D {}; +jest.mock('cc', () => ({ Texture2D: mockTexture, js: { getClassName: (value: any) => value.type } })); +import { withLightmapTextureType } from '../scene-process/service/dump/lightmap-metadata'; + +describe('Lightmap texture snapshot metadata', () => { + it.each(['cc.ModelBakeSettings', 'cc.TerrainBlockLightmapInfo'])('types even cleared texture references on %s without changing engine metadata', type => { + const attributes = Object.freeze({ default: null }); + expect(withLightmapTextureType(attributes, { type }, 'texture')).toEqual({ default: null, ctor: mockTexture }); + expect(attributes).toEqual({ default: null }); + }); + it('preserves declared constructors', () => { + const attributes = { ctor: class CustomTexture {} }; + expect(withLightmapTextureType(attributes, { type: 'cc.ModelBakeSettings' }, 'texture')).toBe(attributes); + }); + it.each([[null, 'texture'], [{ type: 'cc.Other' }, 'texture'], [{ type: 'cc.ModelBakeSettings' }, 'uvParam']])('does not change unrelated properties (%p, %s)', (owner, key) => { + const attributes = {}; + expect(withLightmapTextureType(attributes, owner as object | null, key as string)).toBe(attributes); + }); +}); diff --git a/src/core/scene/test/lightmap-result-recording.test.ts b/src/core/scene/test/lightmap-result-recording.test.ts new file mode 100644 index 000000000..548ea78db --- /dev/null +++ b/src/core/scene/test/lightmap-result-recording.test.ts @@ -0,0 +1,85 @@ +const mockGetScene = jest.fn(); +const mockMeshRenderer = class MeshRenderer {}; +const mockTerrain = class Terrain {}; +const mockBake = jest.fn(); +const mockCommit = jest.fn(); +const mockRollback = jest.fn(); +const mockUndo = { + beginRecording: jest.fn(() => 'recording'), + endRecording: jest.fn(async () => undefined), + cancelRecording: jest.fn(), + createCheckpoint: jest.fn(() => ({ commandId: 'recording', generation: 1 })), + markSaved: jest.fn(), +}; +const mockSave = jest.fn(async () => undefined); +jest.mock('cc', () => ({ director: { getScene: mockGetScene }, MeshRenderer: mockMeshRenderer, Terrain: mockTerrain })); +jest.mock('../scene-process/service/core', () => ({ + BaseService: class { broadcast() {} }, register: () => () => undefined, + Service: { Undo: mockUndo, Editor: { save: mockSave }, Engine: { repaintInEditMode: async () => undefined } }, +})); +jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { bake: mockBake, commit: mockCommit, rollback: mockRollback } })); +jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { + reserveSceneOperation: async () => ({ transactionId: 'owner' }), releaseSceneOperation: async () => undefined, +} })); +jest.mock('../scene-process/service/baking/lightfx/settings', () => ({ createDefaultLightFXSettings: () => ({}) })); +jest.mock('../scene-process/service/preview/asset-reload', () => ({ loadPreviewAsset: jest.fn() })); +jest.mock('../scene-process/rpc', () => ({ Rpc: { getInstance: jest.fn() } })); +import { LightmapBakeService } from '../scene-process/service/lightmap-bake'; + +function fixture() { + const oldTexture = { uuid: 'old-texture' }; + const model = { uuid: 'mesh', node: {}, bakeSettings: { texture: oldTexture, uvParam: { clone: () => ({ x: 1, y: 2, z: 3, w: 4 }) } }, _updateLightmap: jest.fn() }; + const terrain = { uuid: 'terrain', lightMapSize: 64, _lightmapInfos: [ + { texture: oldTexture, UOff: 1, VOff: 2, UScale: 3, VScale: 4 }, + { texture: oldTexture, UOff: 5, VOff: 6, UScale: 7, VScale: 8 }, + ], _resetLightmap: jest.fn(), _updateLightmap: jest.fn() }; + const scene = { uuid: 'scene', name: 'test', globals: { bakedWithHighpLightmap: false, bakedWithStationaryMainLight: false }, + children: [], getComponents: (type: unknown) => type === mockMeshRenderer ? [model] : type === mockTerrain ? [terrain] : [], + }; + mockGetScene.mockReturnValue(scene); + const service = new LightmapBakeService(); + jest.spyOn(service as any, 'querySceneUrl').mockResolvedValue('db://assets/test.scene'); + const texture = { uuid: 'new-texture' }; + jest.spyOn(service as any, 'loadOutputTextures').mockResolvedValue(new Map([['mesh:0', texture], ['terrain:0', texture]])); + mockBake.mockResolvedValue({ models: [model], terrains: [terrain], operationId: 'operation', stationaryMainLight: true, textureUrls: [], result: { + meshes: [{ id: 0, index: 0, offset: [0.1, 0.2], scale: [0.3, 0.4] }], + terrains: [{ id: 0, index: 0, blockId: 1, offset: [0.5, 0.6], scale: [0.7, 0.8] }], + } }); + return { service, model, terrain, texture, oldTexture }; +} + +describe('Lightmap result recording targets', () => { + beforeEach(() => jest.clearAllMocks()); + it.each([false, true])('records Mesh and Terrain components before scene flags for Bake (save=%s)', async saveScene => { + const f = fixture(); + await f.service.bake({ saveScene }); + expect(mockUndo.beginRecording).toHaveBeenCalledWith(['mesh', 'terrain', 'scene'], { label: 'Bake lightmap' }); + expect(f.model._updateLightmap).toHaveBeenCalledWith(f.texture, 0.1, 0.2, 0.3, 0.4); + expect(f.terrain._updateLightmap).toHaveBeenCalledWith(1, f.texture, 0.5, 0.6, 0.7, 0.8); + expect(mockUndo.endRecording).toHaveBeenCalledWith('recording'); + expect(mockCommit).toHaveBeenCalledWith('operation'); + expect(mockSave).toHaveBeenCalledTimes(saveScene ? 1 : 0); + expect(mockUndo.markSaved).toHaveBeenCalledTimes(saveScene ? 1 : 0); + }); + it.each([false, true])('deduplicates multiple Terrain blocks and records all cleared bindings (save=%s)', async saveScene => { + const f = fixture(); + await expect(f.service.clearBake({ saveScene })).resolves.toEqual({ clearedCount: 3 }); + expect(mockUndo.beginRecording).toHaveBeenCalledWith(['mesh', 'terrain', 'scene'], { label: 'Clear lightmap' }); + expect(f.model._updateLightmap).toHaveBeenCalledWith(null, 0, 0, 0, 0); + expect(f.terrain._updateLightmap).toHaveBeenCalledWith(0, null, 0, 0, 0, 0); + expect(f.terrain._updateLightmap).toHaveBeenCalledWith(1, null, 0, 0, 0, 0); + expect(mockUndo.endRecording).toHaveBeenCalledWith('recording'); + expect(mockSave).toHaveBeenCalledTimes(saveScene ? 1 : 0); + expect(mockUndo.markSaved).toHaveBeenCalledTimes(saveScene ? 1 : 0); + }); + it('restores bindings and cancels the recording if saving fails', async () => { + const f = fixture(); + mockSave.mockRejectedValueOnce(new Error('disk unavailable')); + await expect(f.service.clearBake()).rejects.toThrow('disk unavailable'); + expect(mockUndo.cancelRecording).toHaveBeenCalledWith('recording'); + expect(mockUndo.endRecording).not.toHaveBeenCalled(); + expect(mockUndo.markSaved).not.toHaveBeenCalled(); + expect(f.model._updateLightmap).toHaveBeenLastCalledWith(f.oldTexture, 1, 2, 3, 4); + expect(f.terrain._updateLightmap).toHaveBeenLastCalledWith(1, f.oldTexture, 5, 6, 7, 8); + }); +}); From 1fac1df132eb307af2c33a02b1b68b3fc5a23e4b Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 14:51:09 +0800 Subject: [PATCH 17/64] =?UTF-8?q?fix(scene):=20=E6=8C=89=E7=83=98=E7=84=99?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E4=BF=9D=E7=95=99=E5=85=89=E7=85=A7=E8=B4=B4?= =?UTF-8?q?=E5=9B=BE=E8=B5=84=E4=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 20 +++-- .../scene/main-process/lightfx-bake-host.ts | 7 +- .../scene/test/lightfx-asset-versions.test.ts | 78 +++++++++++++++++++ 3 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 src/core/scene/test/lightfx-asset-versions.test.ts diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 6e06ff59a..d3440188a 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -177,8 +177,8 @@ Probe Bake/Clear 的 `saveScene` 默认是 `true`:完整成功后,当前 "data": { "sceneUrl": "db://assets/LightProbe.scene", "textureUrls": [ - "db://assets/LightProbe/lightmap/LFX_Mesh_0000.png", - "db://assets/LightProbe/lightmap/LFX_Terrain_0000.png" + "db://assets/LightProbe/lightmap/bake-/LFX_Mesh_0000.png", + "db://assets/LightProbe/lightmap/bake-/LFX_Terrain_0000.png" ], "meshCount": 7, "terrainCount": 1, @@ -283,10 +283,10 @@ Pink 应在场景打开、烘焙完成和清理完成后调用该工具刷新面 ## Lightmap 资产规则 -Lightmap 统一输出到: +Lightmap 按每次烘焙的 operation UUID 输出到独立版本目录(以下为路径模板): ```text -db://assets//lightmap/ +db://assets//lightmap/bake-/ ``` 典型文件包括: @@ -297,9 +297,11 @@ LFX_Terrain_0000.png ``` - Mesh 与 Terrain 使用独立的类型和索引映射,避免两者均从索引 0 开始时串绑贴图。 -- 重复烘焙会保留同名贴图的 `.meta`,从而复用 Asset UUID。 +- 每次成功烘焙创建新的 URL/Asset UUID,不覆盖任何已发布版本。Undo 恢复旧纹理引用时,旧 PNG 像素仍可用;saveScene:false 的新结果也不会改写磁盘旧场景依赖的贴图。 +- 旧版平铺目录中的 PNG/`.meta` 原样保留,不自动迁移、不复用其 UUID。调用方必须使用返回的 textureUrls 或真实绑定查询,不拼接固定文件路径。 +- 历史版本暂不自动回收,因此磁盘占用随烘焙次数增加。不能只按“当前场景没绑定”删除旧版本,Undo、其他场景或磁盘已保存版本可能仍在引用。 - 导入后将 `fixAlphaTransparencyArtifacts` 设置为 `false`,再加载 Texture2D 子资源并绑定。 -- 资产导入、组件绑定或场景保存失败时,恢复原贴图目录、组件绑定和场景全局标记。 +- 资产导入、组件绑定或场景保存失败时,回滚本次新目录并尝试恢复组件绑定和场景全局标记,旧版本目录不受影响。 - 成功、失败、取消和超时都会清理本次 LightFX workspace。 ## Creator 互操作说明 @@ -335,7 +337,7 @@ LightFX 当前可能输出 Creator 历史协议版本。解析器只接受已知 Bake 和 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与结果修改的 MeshRenderer/Terrain 组件(同一 Terrain 多 block 去重)及 Scene 标记,不能只录制不递归的 Scene 根节点;旧引擎缺类型的空纹理引用也会保留在快照中。成功自动保存后以该记录作为保存点;saveScene:false 不隐式保存场景。场景结果提交失败时尝试恢复原组件数据和全局标记;Lightmap 资产提交失败时还会恢复原 PNG 与 `.meta`。 -注意绑定历史与资产版本是两件事:以上 Undo 恢复纹理引用、UV 和场景标记,不承诺重复 Bake 覆盖同一 PNG 后能恢复上一版像素。`deleteAssets:true` 还涉及目录删除,不属于可恢复绑定的保留资产验证范围,调用方不可据此假定删除可撤销。 +绑定历史与资产版本分别保留:新版本输出不会覆盖旧 PNG,Undo 通过旧 UUID 恢复旧纹理/UV/场景标记。此前旧版本 CLI 已覆盖丢失的像素无法靠此修复找回。`deleteAssets:true` 仍会删除同 sceneName 的整个 lightmap 目录,包括所有版本;不属于可恢复绑定的保留资产验证范围,调用方不可据此假定删除可撤销或跨同名场景安全。 ## 验证范围 @@ -345,10 +347,12 @@ Bake 和 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与 - Mesh Lightmap Bake/Clear。 - Terrain Lightmap Bake/Clear。 - Mesh 与 Terrain 混合场景的独立贴图绑定。 -- 重复烘焙的 `.meta` 与 UUID 复用。 +- 重复烘焙的独立版本目录/UUID、旧像素保留及旧平铺资产兼容。 - Pink 当前可见场景中的即时结果应用、清理和取消。 - TypeScript 编译、ESLint、API、协议和资产事务测试。 新增材质类型、灯光类型、LightFX 版本或目标平台时,应补充对应真实场景回归。 2026-09-10 结果历史专项:macOS arm64/隔离 PinK,真实带第二套 UV 的 Mesh 烘焙 128px 标准/高精度贴图;Bake、保留资产的 Clear、独立 Undo/Redo、渲染模型 UV、显式/自动保存、真正关闭重开通过,旁侧 43 点探针全部 SH 保持。Terrain 多 block 录制目标及失败恢复由服务测试覆盖,未在本次专项重做 Terrain 原生场景实测;也没有验收旧 PNG 像素版本撤销、资产删除撤销或最终画面质量。 + +随后版本隔离专项补验:三次真实 Mesh Bake 使用不同 URL/UUID,标准/高精度 PNG 的 SHA256 随 Undo/Redo 精确对应旧/新结果,关闭重开保留;未保存新 Bake 时磁盘 Scene 仍引用未变更的旧 PNG。旧平铺资产保持。真实文件事务测试覆盖同名场景多次输出互不覆盖、本次回滚/导入失败不影响旧版本;取消故障不作为新增实机验收,资产删除与历史 GC 仍待专门的归属协议。 diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index c0f2a6d70..80a026168 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -185,8 +185,11 @@ export class LightFXBakeHost implements ILightFXBakeHostService { ); const tmpDir = join(workspace, 'tmp'); const outputDir = join(workspace, 'output'); - const targetDir = join(assetRoot, options.sceneName, 'lightmap'); - const targetUrl = `db://assets/${options.sceneName}/lightmap`; + // Published textures are immutable: existing saved scenes and Undo + // records may still refer to any earlier bake, including legacy files. + const version = `bake-${operationId}`; + const targetDir = join(assetRoot, options.sceneName, 'lightmap', version); + const targetUrl = `db://assets/${options.sceneName}/lightmap/${version}`; const operation: LightFXHostOperation = { id: operationId, target: options.target, diff --git a/src/core/scene/test/lightfx-asset-versions.test.ts b/src/core/scene/test/lightfx-asset-versions.test.ts new file mode 100644 index 000000000..147c93c0f --- /dev/null +++ b/src/core/scene/test/lightfx-asset-versions.test.ts @@ -0,0 +1,78 @@ +import { mkdtemp, outputFile, pathExists, readFile, remove } from 'fs-extra'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const mockAssets = { + queryPath: jest.fn(), refreshAsset: jest.fn(), queryUUID: jest.fn(), + queryAssetMeta: jest.fn(() => ({ userData: { fixAlphaTransparencyArtifacts: false } })), +}; +const mockRun = jest.fn(); +jest.mock('../../assets', () => ({ assetManager: mockAssets })); +jest.mock('../main-process/lightfx/process', () => ({ LightFXProcess: jest.fn(() => ({ run: mockRun, cancel: async () => undefined })) })); +jest.mock('../main-process/lightfx/output', () => ({ decodeLightFXOutput: () => ({ version: 1, meshes: [], terrains: [], probes: [] }) })); +import { LightFXBakeHost } from '../main-process/lightfx-bake-host'; + +describe('Immutable Lightmap asset versions', () => { + let root: string; + let assetRoot: string; + let host: LightFXBakeHost; + const assetPath = (url: string) => join(assetRoot, url.slice('db://assets/'.length)); + const opts = { target: 'lightmap' as const, sceneName: 'SharedName', textureSources: [], timeoutMs: 120_000 }; + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'lightfx-versions-')); + assetRoot = join(root, 'assets'); + mockAssets.queryPath.mockReset().mockReturnValue(assetRoot); + mockAssets.refreshAsset.mockReset().mockResolvedValue(undefined); + // Asset identity stands for its unique import URL; the filesystem transaction is real. + mockAssets.queryUUID.mockReset().mockImplementation((url: string) => url); + mockRun.mockReset(); + host = new LightFXBakeHost(); + }); + afterEach(async () => { await host.dispose(); await remove(root); }); + + async function bake(bytes: string) { + mockRun.mockImplementationOnce(async ({ cwd }: { cwd: string }) => { + await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); + await outputFile(join(cwd, 'output', 'LFX_Mesh_0000.png'), bytes); + }); + const token = await host.begin(opts); + await host.appendInput({ ...token, chunkBase64: Buffer.from('input').toString('base64') }); + const output = await host.run(token); + return { token, url: output.textureUrls[0], path: assetPath(output.textureUrls[0]) }; + } + + it('publishes distinct assets across same-name bakes without touching legacy files or earlier versions', async () => { + const legacy = join(assetRoot, opts.sceneName, 'lightmap', 'LFX_Mesh_0000.png'); + await outputFile(legacy, 'legacy pixels'); + await outputFile(`${legacy}.meta`, 'legacy UUID'); + const a = await bake('pixels A'); + await host.commit(a.token); + const b = await bake('pixels B'); + await host.commit(b.token); + expect(a.url).not.toBe(b.url); + expect(a.url).toContain(`/bake-${a.token.operationId}/LFX_Mesh_0000.png`); + expect(b.url).toContain(`/bake-${b.token.operationId}/LFX_Mesh_0000.png`); + expect(await readFile(a.path, 'utf8')).toBe('pixels A'); + expect(await readFile(b.path, 'utf8')).toBe('pixels B'); + expect(await readFile(legacy, 'utf8')).toBe('legacy pixels'); + expect(await readFile(`${legacy}.meta`, 'utf8')).toBe('legacy UUID'); + }); + + it('rolls back only the current version and retains the previous published assets', async () => { + const a = await bake('pixels A'); + await host.commit(a.token); + const b = await bake('pixels B'); + await host.rollback(b.token); + expect(await readFile(a.path, 'utf8')).toBe('pixels A'); + expect(await pathExists(b.path)).toBe(false); + }); + + it('keeps previous assets when importing a new version fails', async () => { + const a = await bake('pixels A'); + await host.commit(a.token); + mockAssets.refreshAsset.mockRejectedValueOnce(new Error('import unavailable')); + await expect(bake('pixels B')).rejects.toThrow('import unavailable'); + expect(await readFile(a.path, 'utf8')).toBe('pixels A'); + await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, busy: false }); + }); +}); From 60cb8c832fb8e1c4de19cc53f33330cd6780016b Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 14:57:25 +0800 Subject: [PATCH 18/64] =?UTF-8?q?fix(scene):=20=E5=88=B7=E6=96=B0=E5=9C=B0?= =?UTF-8?q?=E5=BD=A2=E5=85=89=E7=85=A7=E8=B4=B4=E5=9B=BE=E6=92=A4=E9=94=80?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E6=B8=B2=E6=9F=93=E5=9D=97=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 2 ++ .../service/dump/terrain-lightmap-restore.ts | 13 ++++++++ .../undo/commands/command-utils-shared.ts | 2 ++ .../test/terrain-lightmap-restore.test.ts | 33 +++++++++++++++++++ src/core/scene/test/undo-node-restore.test.ts | 18 ++++++++++ 5 files changed, 68 insertions(+) create mode 100644 src/core/scene/scene-process/service/dump/terrain-lightmap-restore.ts create mode 100644 src/core/scene/test/terrain-lightmap-restore.test.ts diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index d3440188a..b9284b6a5 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -356,3 +356,5 @@ Bake 和 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与 2026-09-10 结果历史专项:macOS arm64/隔离 PinK,真实带第二套 UV 的 Mesh 烘焙 128px 标准/高精度贴图;Bake、保留资产的 Clear、独立 Undo/Redo、渲染模型 UV、显式/自动保存、真正关闭重开通过,旁侧 43 点探针全部 SH 保持。Terrain 多 block 录制目标及失败恢复由服务测试覆盖,未在本次专项重做 Terrain 原生场景实测;也没有验收旧 PNG 像素版本撤销、资产删除撤销或最终画面质量。 随后版本隔离专项补验:三次真实 Mesh Bake 使用不同 URL/UUID,标准/高精度 PNG 的 SHA256 随 Undo/Redo 精确对应旧/新结果,关闭重开保留;未保存新 Bake 时磁盘 Scene 仍引用未变更的旧 PNG。旧平铺资产保持。真实文件事务测试覆盖同名场景多次输出互不覆盖、本次回滚/导入失败不影响旧版本;取消故障不作为新增实机验收,资产删除与历史 GC 仍待专门的归属协议。 + +Terrain 专项补验:快照恢复数组后,对已有 TerrainBlock 重新绑定对应 lightmap info(无元素时解绑)并让材质失效,避免 Terrain.onRestore 的 valid 快路径保留旧引用。实际单块和持久化 `.terrain` 双块+Mesh 混合场景,Bake/Clear、Undo/Redo、自动/显式保存、关闭重开通过;每个 block 的实际 texture/UV 与序列化结果一致,43 点探针 SH 不变。`bake().terrainCount` 当前是原生输出 block 条目数,`queryBakeInfo().terrainCount` 是拥有绑定的 Terrain 组件数,两者不应直接比较。地形尺寸/高度保存在 `.terrain` 资产,夹具通过 Terrain.saveManage/saveAssetDialog 正式写入,不靠修改内存后只保存 Scene 冒充持久化。 diff --git a/src/core/scene/scene-process/service/dump/terrain-lightmap-restore.ts b/src/core/scene/scene-process/service/dump/terrain-lightmap-restore.ts new file mode 100644 index 000000000..dde81085f --- /dev/null +++ b/src/core/scene/scene-process/service/dump/terrain-lightmap-restore.ts @@ -0,0 +1,13 @@ +interface TerrainLightmapRestoreTarget { + _lightmapInfos: readonly unknown[]; + getBlocks(): readonly { _updateLightmap(info: unknown): void }[]; +} + +/** Rebind existing blocks after dump restoration replaces their serialized lightmap entries. */ +export function restoreTerrainLightmapBindings(component: object, dump: { type?: string; extends?: string[]; value?: object }): void { + if ((dump.type !== 'cc.Terrain' && !dump.extends?.includes('cc.Terrain')) || !dump.value || !('_lightmapInfos' in dump.value)) return; + const terrain = component as TerrainLightmapRestoreTarget; + // Terrain.onRestore may retain already-built blocks. Update their references + // and invalidate their material even when the restored array is empty. + terrain.getBlocks().forEach((block, index) => block._updateLightmap(terrain._lightmapInfos[index] ?? null)); +} diff --git a/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts b/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts index c6360bcde..a642b74ed 100644 --- a/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts +++ b/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts @@ -1,5 +1,6 @@ import { Component, Node } from 'cc'; import type { IUndoCommandMeta, IUndoRedoResult } from '../../../../common'; +import { restoreTerrainLightmapBindings } from '../../dump/terrain-lightmap-restore'; export function createUndoId(prefix: string): string { try { @@ -138,4 +139,5 @@ export async function restoreComponentSnapshotDump( const { default: dumpUtil } = await import('../../dump'); await dumpUtil.restoreComponentSnapshotProperties(component, dump); (component as any).onRestore?.(); + restoreTerrainLightmapBindings(component, dump); } diff --git a/src/core/scene/test/terrain-lightmap-restore.test.ts b/src/core/scene/test/terrain-lightmap-restore.test.ts new file mode 100644 index 000000000..b6da86c33 --- /dev/null +++ b/src/core/scene/test/terrain-lightmap-restore.test.ts @@ -0,0 +1,33 @@ +import { restoreTerrainLightmapBindings } from '../scene-process/service/dump/terrain-lightmap-restore'; + +describe('Terrain lightmap snapshot rendering', () => { + const dump = { type: 'cc.Terrain', value: { _lightmapInfos: {} } }; + function fixture(infos: unknown[]) { + const blocks = [{ _updateLightmap: jest.fn() }, { _updateLightmap: jest.fn() }]; + return { component: { _lightmapInfos: infos, getBlocks: () => blocks }, blocks }; + } + it('rebinds every block to the restored entry without copying or changing the serialized array', () => { + const infos = [{ texture: 'A' }, { texture: 'B' }]; + const f = fixture(infos); + restoreTerrainLightmapBindings(f.component, dump); + expect(f.blocks[0]._updateLightmap).toHaveBeenCalledWith(infos[0]); + expect(f.blocks[1]._updateLightmap).toHaveBeenCalledWith(infos[1]); + expect(f.component._lightmapInfos).toBe(infos); + expect(infos).toEqual([{ texture: 'A' }, { texture: 'B' }]); + }); + it.each([[[]], [[{ texture: null }]]])('explicitly unbinds missing entries after restoring %p', infos => { + const f = fixture(infos); + restoreTerrainLightmapBindings(f.component, dump); + expect(f.blocks[0]._updateLightmap).toHaveBeenCalledWith(infos[0] ?? null); + expect(f.blocks[1]._updateLightmap).toHaveBeenCalledWith(null); + }); + it('handles derived terrain components and not-yet-built terrain', () => { + const f = fixture([]); + restoreTerrainLightmapBindings(f.component, { ...dump, type: 'CustomTerrain', extends: ['cc.Terrain'] }); + expect(f.blocks[0]._updateLightmap).toHaveBeenCalledWith(null); + expect(() => restoreTerrainLightmapBindings({ _lightmapInfos: [], getBlocks: () => [] }, dump)).not.toThrow(); + }); + it.each([{ type: 'cc.MeshRenderer', value: { _lightmapInfos: {} } }, { type: 'cc.Terrain', value: {} }, { type: 'cc.Terrain' }])('does not touch unrelated or partial snapshots (%p)', other => { + restoreTerrainLightmapBindings({}, other); + }); +}); diff --git a/src/core/scene/test/undo-node-restore.test.ts b/src/core/scene/test/undo-node-restore.test.ts index 93d1cd1ea..d4dae510b 100644 --- a/src/core/scene/test/undo-node-restore.test.ts +++ b/src/core/scene/test/undo-node-restore.test.ts @@ -69,6 +69,24 @@ describe('restoreComponentSnapshotDump', () => { mockRestoreComponentSnapshotProperties.mockReset(); }); + it('refreshes Terrain block bindings after properties and the engine lifecycle have restored', async () => { + const events: string[] = []; + const restoredInfo = { texture: 'restored-texture' }; + const block = { _updateLightmap: jest.fn(() => events.push('bind')) }; + const component = { + _lightmapInfos: [] as unknown[], + onRestore: () => { events.push('lifecycle'); }, + getBlocks: () => [block], + }; + mockRestoreComponentSnapshotProperties.mockImplementationOnce(async () => { + events.push('properties'); + component._lightmapInfos = [restoredInfo]; + }); + await restoreComponentSnapshotDump(component as any, { type: 'cc.Terrain', value: { _lightmapInfos: {} } }); + expect(events).toEqual(['properties', 'lifecycle', 'bind']); + expect(block._updateLightmap).toHaveBeenCalledWith(restoredInfo); + }); + it('delegates property restoration to dump and calls onRestore lifecycle', async () => { const component = { onRestore: jest.fn(), From 831ac214b214d906e79b26e0353ffe0926ab0cfb Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 15:01:11 +0800 Subject: [PATCH 19/64] =?UTF-8?q?feat(scene):=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E5=85=89=E7=85=A7=E8=B4=B4=E5=9B=BE=E7=BB=93=E6=9E=9C=E4=B8=8E?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E7=89=88=E6=9C=AC=E8=83=BD=E5=8A=9B=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 9 ++++++++ src/core/scene/common/lightfx-bake.ts | 15 ++++++++++++- src/core/scene/common/lightfx-host.ts | 2 ++ .../scene/main-process/lightfx-bake-host.ts | 2 +- .../main-process/proxy/lightfx-bake-proxy.ts | 4 ++++ .../scene/scene-process/engine-bootstrap.ts | 2 +- .../scene-process/service/lightmap-bake.ts | 10 ++++++++- .../scene/test/lightfx-asset-versions.test.ts | 2 +- src/core/scene/test/lightfx-bake-host.test.ts | 2 +- .../scene/test/lightfx-bake-renderer.test.ts | 10 ++++----- .../test/lightfx-scene-entrances.test.ts | 21 +++++++++++++++++++ 11 files changed, 68 insertions(+), 11 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index b9284b6a5..bafc5400b 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -32,6 +32,15 @@ const capabilities = await cli.Scene.LightProbeBake.queryCapabilities(); `resultLifecycleVersion: 1` 表明本 Scene 实现包含 SH Undo/Redo 与多组重开结果保留修复;`sceneTransactionVersion: 1` 表明 Scene 与实际 host 均使用完整 Bake/Clear 事务预留协议。旧 host 缺少查询或协议不匹配时拒绝返回能力,调用方不能只检测 bake 方法存在或只检查包版本。集成方遇到方法缺失/查询失败应显示不支持或连接错误,不得自动尝试烘焙。 +Lightmap 使用独立能力查询,不能复用 Probe 的生命周期判断: + +```ts +const capabilities = await cli.Scene.LightmapBake.queryCapabilities(); +// { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, busy: false } +``` + +这里的 `resultLifecycleVersion: 1` 包含 Mesh/Terrain 的结果录制目标、空纹理引用、TerrainBlock 恢复刷新及保存基线;`assetVersion: 1` 必须由实际 Node host 的 `lightmapAssetVersion: 1` 确认,保证新 Bake 不覆盖旧纹理版本。旧 host 即使支持 Probe 事务,也可能缺少资产版本保护,此时 Lightmap 查询拒绝返回支持。该能力只覆盖保留资产的 Clear,不承诺 deleteAssets 删除归属、资产 GC 或有归属取消。 + `busy` 仅为共享宿主的瞬时占用提示,包含导出前预留、原生操作、提交后场景回写及失败恢复;查询不占锁、不释放锁、不返回内部凭据。即使 busy=false,执行入口仍需原子预留,调用方必须处理查询之后发生的并发拒绝。该接口不检查原生 LightFX 可执行文件、场景输入合法性或渲染质量,也不是可恢复的任务状态/百分比/有归属取消接口。新旧 renderer 混用的限制仍见下文。 ## MCP 工具 diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts index 339a55c30..3800a2e2a 100644 --- a/src/core/scene/common/lightfx-bake.ts +++ b/src/core/scene/common/lightfx-bake.ts @@ -54,6 +54,17 @@ export interface ILightmapBakeOptions { timeoutMs?: number; } +/** Implementation support, not native executable readiness, task recovery or safe asset deletion. */ +export interface ILightmapBakeCapabilities { + version: 1; + /** Mesh/Terrain bindings, null references and live blocks are restored with the result history. */ + resultLifecycleVersion: 1; + sceneTransactionVersion: 1; + /** The actual host preserves previous textures in immutable per-operation directories. */ + assetVersion: 1; + busy: boolean; +} + export interface ILightmapBakeResult { sceneUrl: string; textureUrls: string[]; @@ -92,6 +103,8 @@ export interface ILightProbeBakeService extends IServiceEvents { } export interface ILightmapBakeService extends IServiceEvents { + /** Queries this Scene and its actual host without modifying scene or task state. */ + queryCapabilities(): Promise; bake(options: ILightmapBakeOptions): Promise; queryBakeInfo(): Promise; clearBake(options?: { saveScene?: boolean; deleteAssets?: boolean }): Promise<{ clearedCount: number }>; @@ -99,4 +112,4 @@ export interface ILightmapBakeService extends IServiceEvents { } export type IPublicLightProbeBakeService = Pick; -export type IPublicLightmapBakeService = Pick; +export type IPublicLightmapBakeService = Pick; diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts index d0c001965..efedfef33 100644 --- a/src/core/scene/common/lightfx-host.ts +++ b/src/core/scene/common/lightfx-host.ts @@ -14,6 +14,8 @@ export interface ILightFXSceneOperationToken { /** Read-only host protocol snapshot. Busy is advisory, not permission to start a transaction. */ export interface ILightFXHostCapabilities { sceneTransactionVersion: 1; + /** Absent on legacy hosts; version 1 publishes immutable per-operation Lightmap assets. */ + lightmapAssetVersion?: 1; busy: boolean; } diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index 80a026168..4fd1b9e1f 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -81,7 +81,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { private readonly releasedSceneOperations = new Set(); public async queryCapabilities(): Promise { - return { sceneTransactionVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; + return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; } public async reserveSceneOperation(options: IReserveLightFXSceneOperationOptions): Promise { diff --git a/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts b/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts index c26c41a61..d54915a09 100644 --- a/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts +++ b/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts @@ -21,6 +21,10 @@ export const LightProbeBakeProxy: IPublicLightProbeBakeService = { }; export const LightmapBakeProxy: IPublicLightmapBakeService = { + queryCapabilities: () => lightFXBakeRenderer.invoke( + 'LightmapBake', 'queryCapabilities', [], 30_000, + () => Rpc.getInstance().request('LightmapBake', 'queryCapabilities'), + ), bake: (options) => lightFXBakeRenderer.invoke( 'LightmapBake', 'bake', [options], (options.timeoutMs ?? 600_000) + 30_000, () => Rpc.getInstance().request('LightmapBake', 'bake', [options]), true, diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index 913bc0b3e..2cf9417bd 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -290,7 +290,7 @@ async function setupBrowserInvokeChannel(serverURL: string) { const methods = msg?.module === 'LightProbeBake' ? new Set(['bake', 'queryCapabilities', 'clearBake', 'cancel']) : msg?.module === 'LightmapBake' - ? new Set(['bake', 'queryBakeInfo', 'clearBake', 'cancel']) + ? new Set(['bake', 'queryCapabilities', 'queryBakeInfo', 'clearBake', 'cancel']) : null; if (!methods?.has(msg.method || '')) { throw new Error('Invalid LightFX scene request.'); diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index d30deba00..fac990fe6 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -1,7 +1,7 @@ import { director, MeshRenderer, Scene, Terrain, Texture2D } from 'cc'; import type { ILightFXBakeEvents, ILightFXCancelResult, ILightmapBakeOptions, - ILightmapBakeInfo, ILightmapBakeResult, ILightmapBakeService, + ILightmapBakeInfo, ILightmapBakeResult, ILightmapBakeService, ILightmapBakeCapabilities, } from '../../common'; import { Rpc } from '../rpc'; import { lightFXCoordinator } from './baking/lightfx/baker'; @@ -22,6 +22,14 @@ interface LightmapBinding { @register('LightmapBake') export class LightmapBakeService extends BaseService implements ILightmapBakeService { + async queryCapabilities(): Promise { + const host = await lightFXBakeHost.queryCapabilities(); + if (host?.sceneTransactionVersion !== 1 || host.lightmapAssetVersion !== 1 || typeof host.busy !== 'boolean') { + throw new Error('The LightFX host does not support scene transaction and immutable Lightmap asset protocol version 1.'); + } + return { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, busy: host.busy }; + } + async bake(options: ILightmapBakeOptions = {}): Promise { return lightFXSceneOperation.run('lightmap', 'bake', () => this.bakeExclusive(options)); } diff --git a/src/core/scene/test/lightfx-asset-versions.test.ts b/src/core/scene/test/lightfx-asset-versions.test.ts index 147c93c0f..910850d58 100644 --- a/src/core/scene/test/lightfx-asset-versions.test.ts +++ b/src/core/scene/test/lightfx-asset-versions.test.ts @@ -73,6 +73,6 @@ describe('Immutable Lightmap asset versions', () => { mockAssets.refreshAsset.mockRejectedValueOnce(new Error('import unavailable')); await expect(bake('pixels B')).rejects.toThrow('import unavailable'); expect(await readFile(a.path, 'utf8')).toBe('pixels A'); - await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, busy: false }); + await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, busy: false }); }); }); diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts index dd9a727a2..fd4f0ce6c 100644 --- a/src/core/scene/test/lightfx-bake-host.test.ts +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -73,7 +73,7 @@ describe('LightFXBakeHost', () => { } it('queries protocol and occupancy without reserving, releasing or exposing ownership', async () => { - const idle = { sceneTransactionVersion: 1, busy: false }; + const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, busy: false }; const busy = { ...idle, busy: true }; await expect(host.queryCapabilities()).resolves.toEqual(idle); const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); diff --git a/src/core/scene/test/lightfx-bake-renderer.test.ts b/src/core/scene/test/lightfx-bake-renderer.test.ts index fcdaa3ce6..edee8e133 100644 --- a/src/core/scene/test/lightfx-bake-renderer.test.ts +++ b/src/core/scene/test/lightfx-bake-renderer.test.ts @@ -80,21 +80,21 @@ describe('LightFX active scene renderer routing', () => { expect(fallback).toHaveBeenCalledTimes(1); }); - it('routes a probe capability query to the actual active renderer, with worker fallback only when absent', async () => { - const result = { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, busy: true }; + it.each(['LightProbeBake', 'LightmapBake'] as const)('routes %s capability queries to the actual renderer, with worker fallback only when absent', async module => { + const result = { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, ...(module === 'LightmapBake' ? { assetVersion: 1 } : {}), busy: true }; const visible = createSocket({ id: 'visible', sceneUrl: 'db://assets/Probe.scene', visible: true, result }); useSockets([visible]); const fallback = jest.fn(async () => result); await expect(lightFXBakeRenderer.invoke( - 'LightProbeBake', 'queryCapabilities', [], 30_000, fallback, + module, 'queryCapabilities', [], 30_000, fallback, )).resolves.toEqual(result); expect(fallback).not.toHaveBeenCalled(); expect(visible.emit).toHaveBeenCalledWith('scene:invoke-lightfx', expect.objectContaining({ - module: 'LightProbeBake', method: 'queryCapabilities', sceneUrl: 'db://assets/Probe.scene', + module, method: 'queryCapabilities', sceneUrl: 'db://assets/Probe.scene', }), expect.any(Function)); useSockets([]); await expect(lightFXBakeRenderer.invoke( - 'LightProbeBake', 'queryCapabilities', [], 30_000, fallback, + module, 'queryCapabilities', [], 30_000, fallback, )).resolves.toEqual(result); expect(fallback).toHaveBeenCalledTimes(1); }); diff --git a/src/core/scene/test/lightfx-scene-entrances.test.ts b/src/core/scene/test/lightfx-scene-entrances.test.ts index 5b411ed78..4a18a53f9 100644 --- a/src/core/scene/test/lightfx-scene-entrances.test.ts +++ b/src/core/scene/test/lightfx-scene-entrances.test.ts @@ -16,6 +16,27 @@ import { lightFXSceneOperation } from '../scene-process/service/baking/lightfx/s import { lightFXBakeHost } from '../scene-process/service/baking/lightfx/host'; describe('LightFX service entrance ownership', () => { + it.each([false, true])('queries Lightmap lifecycle and actual host asset support without reserving (busy=%s)', async busy => { + jest.mocked(lightFXBakeHost.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, busy }); + const reserveCalls = jest.mocked(lightFXBakeHost.reserveSceneOperation).mock.calls.length; + await expect(new LightmapBakeService().queryCapabilities()).resolves.toEqual({ + version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, busy, + }); + expect(lightFXBakeHost.reserveSceneOperation).toHaveBeenCalledTimes(reserveCalls); + expect(mockGetScene).not.toHaveBeenCalled(); + }); + + it('rejects Lightmap capability on a legacy, mismatched or unreachable host', async () => { + const query = jest.mocked(lightFXBakeHost.queryCapabilities); + for (const value of [null, {}, { sceneTransactionVersion: 1, busy: false }, { sceneTransactionVersion: 1, lightmapAssetVersion: 2, busy: false }, { sceneTransactionVersion: 2, lightmapAssetVersion: 1, busy: false }, { sceneTransactionVersion: 1, lightmapAssetVersion: 1 }]) { + query.mockResolvedValueOnce(value as Awaited>); + await expect(new LightmapBakeService().queryCapabilities()).rejects.toThrow('protocol version 1'); + } + query.mockRejectedValueOnce(new Error('Disconnected')); + await expect(new LightmapBakeService().queryCapabilities()).rejects.toThrow('Disconnected'); + expect(mockGetScene).not.toHaveBeenCalled(); + }); + it.each([false, true])('queries the actual host without taking a reservation (busy=%s)', async (busy) => { const query = jest.mocked(lightFXBakeHost.queryCapabilities); query.mockResolvedValueOnce({ sceneTransactionVersion: 1, busy }); From 157006c8c7b8dcf8e3711423251e352fca1511d2 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 15:57:13 +0800 Subject: [PATCH 20/64] =?UTF-8?q?fix(scene):=20=E6=8C=89=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E4=B8=8E=E4=BB=BB=E5=8A=A1=E5=BD=92=E5=B1=9E=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E5=85=89=E7=85=A7=E7=83=98=E7=84=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 4 + src/core/scene/common/lightfx-bake.ts | 2 + src/core/scene/common/lightfx-host.ts | 9 ++- .../scene/main-process/lightfx-bake-host.ts | 10 ++- .../main-process/lightfx-bake-renderer.ts | 4 +- .../main-process/proxy/lightfx-bake-proxy.ts | 2 + .../service/baking/lightfx/baker.ts | 22 ++++- .../service/baking/lightfx/host.ts | 3 +- .../scene-process/service/light-probe-bake.ts | 2 +- .../scene-process/service/lightmap-bake.ts | 2 +- .../scene/test/lightfx-asset-versions.test.ts | 2 +- src/core/scene/test/lightfx-bake-host.test.ts | 28 ++++++- .../scene/test/lightfx-bake-renderer.test.ts | 12 +++ .../scene/test/lightfx-cancel-owner.test.ts | 81 +++++++++++++++++++ .../test/lightfx-scene-entrances.test.ts | 9 ++- tests/lightfx-bake-api.test.ts | 18 ++++- 16 files changed, 189 insertions(+), 21 deletions(-) create mode 100644 src/core/scene/test/lightfx-cancel-owner.test.ts diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index bafc5400b..aa0cd71b4 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -288,6 +288,10 @@ Pink 应在场景打开、烘焙完成和清理完成后调用该工具刷新面 没有任务运行时,返回 `cancelled: false` 和 `target: null`。 +Scene 侧 `LightProbeBake.cancel()`/`LightmapBake.cancel()` 只取消本 renderer 内对应类型的 Bake,不再取消共享 host 上其他场景/其他类型的任务。内部请求须带精确 operation ID、目标和 scene transaction ID;缺失/过期归属返回 `cancelled:false`,旧 host 缺少取消归属协议时拒绝请求,不回退到全局取消。导出阶段或 native begin 尚未返回 ID 时也返回 false;因此 false 不一定表示没有烘焙,而是这次请求没有取消任务。 + +通用 MCP 工具保留按 Probe/Lightmap 依次尝试的行为,主进程已跟踪的 Bake 仍路由到原 renderer,不因切标签改投另一个场景。它不是跨客户端认证或公共持久任务句柄;需要严格防止客户端旧消息取消后续任务的 UI,仍须先接入独立任务身份契约。 + 取消成功后,取消工具本身返回 `code: 200`;原烘焙请求结束并返回 `code: 500`、`reason: "LightFX bake was cancelled."`。这是被取消任务的预期终态。 ## Lightmap 资产规则 diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts index 3800a2e2a..cdbf500d3 100644 --- a/src/core/scene/common/lightfx-bake.ts +++ b/src/core/scene/common/lightfx-bake.ts @@ -99,6 +99,7 @@ export interface ILightProbeBakeService extends IServiceEvents { queryCapabilities(): Promise; bake(options: ILightProbeBakeOptions): Promise; clearBake(options?: { saveScene?: boolean }): Promise<{ probeCount: number }>; + /** Cancels only this Scene's probe bake after native ownership is acquired; otherwise a no-op. */ cancel(): Promise; } @@ -108,6 +109,7 @@ export interface ILightmapBakeService extends IServiceEvents { bake(options: ILightmapBakeOptions): Promise; queryBakeInfo(): Promise; clearBake(options?: { saveScene?: boolean; deleteAssets?: boolean }): Promise<{ clearedCount: number }>; + /** Cancels only this Scene's lightmap bake after native ownership is acquired; otherwise a no-op. */ cancel(): Promise; } diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts index efedfef33..6f11a3357 100644 --- a/src/core/scene/common/lightfx-host.ts +++ b/src/core/scene/common/lightfx-host.ts @@ -16,6 +16,8 @@ export interface ILightFXHostCapabilities { sceneTransactionVersion: 1; /** Absent on legacy hosts; version 1 publishes immutable per-operation Lightmap assets. */ lightmapAssetVersion?: 1; + /** Version 1 requires the exact native operation, target and scene reservation to cancel. */ + cancelOwnershipVersion?: 1; busy: boolean; } @@ -90,6 +92,11 @@ export interface ILightFXOperationOptions { operationId: string; } +export interface ICancelLightFXOperationOptions extends ILightFXOperationOptions { + target: LightFXBakeTarget; + transactionId?: string; +} + export interface IRemoveLightmapAssetsOptions { transactionId?: string; sceneName: string; @@ -129,7 +136,7 @@ export interface ILightFXBakeHostService { run(options: IRunLightFXBakeOptions): Promise; commit(options: ILightFXOperationOptions): Promise; rollback(options: ILightFXOperationOptions): Promise; - cancel(): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }>; + cancel(options?: ICancelLightFXOperationOptions): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }>; removeLightmapAssets(options: IRemoveLightmapAssetsOptions): Promise; queryLightmapTextureInfo(options: IQueryLightmapTextureInfoOptions): Promise; } diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index 4fd1b9e1f..85c443a20 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -30,6 +30,7 @@ import type { LightFXBakeTarget, IReserveLightFXSceneOperationOptions, ILightFXSceneOperationToken, + ICancelLightFXOperationOptions, } from '../common/lightfx-host'; import { assetManager } from '../../assets'; import { LightmapAssetTransaction } from './lightfx/asset-transaction'; @@ -81,7 +82,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { private readonly releasedSceneOperations = new Set(); public async queryCapabilities(): Promise { - return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; + return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; } public async reserveSceneOperation(options: IReserveLightFXSceneOperationOptions): Promise { @@ -349,9 +350,12 @@ export class LightFXBakeHost implements ILightFXBakeHostService { await this.cleanup(operation, true); } - public async cancel(): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }> { + public async cancel(options?: ICancelLightFXOperationOptions): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }> { const operation = this.operation; - if (!operation) { + // Missing/late credentials are a no-op, never a request to cancel whoever is now active. + // Legacy native callers without a scene reservation must still name their operation. + if (!operation || !options || options.operationId !== operation.id || options.target !== operation.target + || options.transactionId !== this.sceneOperation?.transactionId) { return { cancelled: false, target: null }; } if (operation.terminalState) { diff --git a/src/core/scene/main-process/lightfx-bake-renderer.ts b/src/core/scene/main-process/lightfx-bake-renderer.ts index ca6c733ab..8c5488745 100644 --- a/src/core/scene/main-process/lightfx-bake-renderer.ts +++ b/src/core/scene/main-process/lightfx-bake-renderer.ts @@ -99,7 +99,7 @@ class LightFXBakeRenderer { } } - async cancel(fallback: () => Promise, timeoutMs = 30_000): Promise { + async cancel(module: LightFXModule, fallback: () => Promise, timeoutMs = 30_000): Promise { const io = socketService.io; if (!io) return fallback(); const sockets = await io.in(SCENE_RENDERER_ROOM).fetchSockets() as RendererSocket[]; @@ -111,7 +111,7 @@ class LightFXBakeRenderer { if (!renderer) { throw new Error('The scene renderer running the LightFX bake is no longer connected.'); } - return requestRenderer(renderer, 'LightProbeBake', 'cancel', [], timeoutMs); + return requestRenderer(renderer, module, 'cancel', [], timeoutMs); } } diff --git a/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts b/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts index d54915a09..0511da657 100644 --- a/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts +++ b/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts @@ -16,6 +16,7 @@ export const LightProbeBakeProxy: IPublicLightProbeBakeService = { () => Rpc.getInstance().request('LightProbeBake', 'clearBake', [options]), ), cancel: () => lightFXBakeRenderer.cancel( + 'LightProbeBake', () => Rpc.getInstance().request('LightProbeBake', 'cancel'), ), }; @@ -38,6 +39,7 @@ export const LightmapBakeProxy: IPublicLightmapBakeService = { () => Rpc.getInstance().request('LightmapBake', 'clearBake', [options]), ), cancel: () => lightFXBakeRenderer.cancel( + 'LightmapBake', () => Rpc.getInstance().request('LightmapBake', 'cancel'), ), }; diff --git a/src/core/scene/scene-process/service/baking/lightfx/baker.ts b/src/core/scene/scene-process/service/baking/lightfx/baker.ts index 7b6961312..ee4ba1a28 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/baker.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/baker.ts @@ -5,6 +5,7 @@ import { LightFXExporter, LightFXExport } from './exporter'; import { lightFXBakeHost } from './host'; import { lightFXSceneOperation } from './scene-operation'; import { LightFXBakeTarget, LightFXResult, LightFXSettings } from './types'; +import type { ICancelLightFXOperationOptions } from '../../../../common/lightfx-host'; const INPUT_CHUNK_SIZE = 512 * 1024; @@ -14,8 +15,9 @@ export interface LightFXBakeOutput extends LightFXExport { textureUrls: string[]; } -class LightFXCoordinator { +export class LightFXCoordinator { private target: LightFXBakeTarget | null = null; + private operation: ICancelLightFXOperationOptions | null = null; get activeTarget(): LightFXBakeTarget | null { return this.target; } @@ -25,13 +27,15 @@ class LightFXCoordinator { let operationId: string | undefined; try { const exported = await new LightFXExporter().export(scene, target, settings); + const transactionId = lightFXSceneOperation.hostTransactionId; ({ operationId } = await lightFXBakeHost.begin({ - transactionId: lightFXSceneOperation.hostTransactionId, + transactionId, target, sceneName: scene.name, textureSources: exported.textureSources, timeoutMs, })); + this.operation = { operationId, transactionId, target }; const input = encodeLightFXInput(exported.world); for (let offset = 0; offset < input.length; offset += INPUT_CHUNK_SIZE) { await lightFXBakeHost.appendInput({ @@ -44,6 +48,7 @@ class LightFXCoordinator { } catch (error) { if (operationId) await lightFXBakeHost.rollback({ operationId }).catch(() => undefined); this.target = null; + this.operation = null; throw error; } } @@ -53,6 +58,7 @@ class LightFXCoordinator { await lightFXBakeHost.commit({ operationId }); } finally { this.target = null; + this.operation = null; } } @@ -61,6 +67,7 @@ class LightFXCoordinator { await lightFXBakeHost.rollback({ operationId }); } finally { this.target = null; + this.operation = null; } } @@ -68,8 +75,15 @@ class LightFXCoordinator { return lightFXBakeHost.removeLightmapAssets({ sceneName, transactionId: lightFXSceneOperation.hostTransactionId }); } - async cancel(): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }> { - return lightFXBakeHost.cancel(); + async cancel(target: LightFXBakeTarget): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }> { + const operation = this.operation; + if (!operation || operation.target !== target) return { cancelled: false, target: null }; + const capabilities = await lightFXBakeHost.queryCapabilities(); + if (capabilities?.cancelOwnershipVersion !== 1) { + throw new Error('LightFX cancellation requires host ownership protocol version 1.'); + } + // Capture before the handshake; neither a late response nor a newer bake may retarget it. + return lightFXBakeHost.cancel(operation); } } diff --git a/src/core/scene/scene-process/service/baking/lightfx/host.ts b/src/core/scene/scene-process/service/baking/lightfx/host.ts index 149087320..3ffd79abf 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/host.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/host.ts @@ -11,6 +11,7 @@ import type { IResolvedLightFXTextureSource, IRunLightFXBakeOptions, IRunLightFXBakeResult, + ICancelLightFXOperationOptions, } from '../../../../common/lightfx-host'; import { Rpc } from '../../../rpc'; @@ -25,7 +26,7 @@ export const lightFXBakeHost: ILightFXBakeHostService = { run: (options: IRunLightFXBakeOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'run', [options]), commit: (options: ILightFXOperationOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'commit', [options]), rollback: (options: ILightFXOperationOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'rollback', [options]), - cancel: (): Promise<{ cancelled: boolean; target: 'light-probe' | 'lightmap' | null }> => Rpc.getInstance().request('lightFXBakeHost', 'cancel'), + cancel: (options?: ICancelLightFXOperationOptions): Promise<{ cancelled: boolean; target: 'light-probe' | 'lightmap' | null }> => Rpc.getInstance().request('lightFXBakeHost', 'cancel', [options]), removeLightmapAssets: (options: IRemoveLightmapAssetsOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'removeLightmapAssets', [options]), queryLightmapTextureInfo: (options: IQueryLightmapTextureInfoOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'queryLightmapTextureInfo', [options]), }; diff --git a/src/core/scene/scene-process/service/light-probe-bake.ts b/src/core/scene/scene-process/service/light-probe-bake.ts index 0c54b0490..2694a7e91 100644 --- a/src/core/scene/scene-process/service/light-probe-bake.ts +++ b/src/core/scene/scene-process/service/light-probe-bake.ts @@ -134,7 +134,7 @@ export class LightProbeBakeService extends BaseService imple } cancel(): Promise { - return lightFXCoordinator.cancel(); + return lightFXCoordinator.cancel('light-probe'); } private async querySceneUrl(): Promise { diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index fac990fe6..d59f47e94 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -185,7 +185,7 @@ export class LightmapBakeService extends BaseService impleme } cancel(): Promise { - return lightFXCoordinator.cancel(); + return lightFXCoordinator.cancel('lightmap'); } private async querySceneUrl(): Promise { diff --git a/src/core/scene/test/lightfx-asset-versions.test.ts b/src/core/scene/test/lightfx-asset-versions.test.ts index 910850d58..61a37660a 100644 --- a/src/core/scene/test/lightfx-asset-versions.test.ts +++ b/src/core/scene/test/lightfx-asset-versions.test.ts @@ -73,6 +73,6 @@ describe('Immutable Lightmap asset versions', () => { mockAssets.refreshAsset.mockRejectedValueOnce(new Error('import unavailable')); await expect(bake('pixels B')).rejects.toThrow('import unavailable'); expect(await readFile(a.path, 'utf8')).toBe('pixels A'); - await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, busy: false }); + await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, busy: false }); }); }); diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts index fd4f0ce6c..9c7f7e6a3 100644 --- a/src/core/scene/test/lightfx-bake-host.test.ts +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -73,7 +73,7 @@ describe('LightFXBakeHost', () => { } it('queries protocol and occupancy without reserving, releasing or exposing ownership', async () => { - const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, busy: false }; + const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, busy: false }; const busy = { ...idle, busy: true }; await expect(host.queryCapabilities()).resolves.toEqual(idle); const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); @@ -256,6 +256,26 @@ describe('LightFXBakeHost', () => { expect(mockAssetManager.queryAssetInfo).toHaveBeenCalledTimes(2); }); + it('rejects unowned and stale cancellation without stopping the current reserved bake', async () => { + const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); + const operationId = await finishLightProbe(token.transactionId); + const current = { operationId, ...token, target: 'light-probe' as const }; + for (const request of [undefined, { ...current, operationId: 'old' }, { ...current, transactionId: undefined }, + { ...current, transactionId: 'other' }, { ...current, target: 'lightmap' as const }]) { + await expect(host.cancel(request)).resolves.toEqual({ cancelled: false, target: null }); + } + expect(mockRunnerCancel).not.toHaveBeenCalled(); + await expect(host.cancel(current)).resolves.toEqual({ cancelled: true, target: 'light-probe' }); + await host.releaseSceneOperation(token); + const nextToken = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); + const nextId = await finishLightProbe(nextToken.transactionId); + await expect(host.cancel(current)).resolves.toEqual({ cancelled: false, target: null }); + await expect(host.cancel({ ...current, ...nextToken })).resolves.toEqual({ cancelled: false, target: null }); + expect(mockRunnerCancel).toHaveBeenCalledTimes(1); + await host.commit({ operationId: nextId }); + await host.releaseSceneOperation(nextToken); + }); + it('reports cancellation instead of an unknown operation when upload continues after cancel', async () => { const { operationId } = await host.begin({ target: 'light-probe', @@ -264,7 +284,7 @@ describe('LightFXBakeHost', () => { timeoutMs: 120_000, }); - await expect(host.cancel()).resolves.toEqual({ cancelled: true, target: 'light-probe' }); + await expect(host.cancel({ operationId, target: 'light-probe' })).resolves.toEqual({ cancelled: true, target: 'light-probe' }); await expect(host.appendInput({ operationId, chunkBase64: Buffer.from('late chunk').toString('base64'), @@ -315,7 +335,7 @@ describe('LightFXBakeHost', () => { it('lets cancel win atomically after run and prevents a stale scene result from committing', async () => { const operationId = await finishLightProbe(); - const cancelling = host.cancel(); + const cancelling = host.cancel({ operationId, target: 'light-probe' }); await expect(host.commit({ operationId })) .rejects.toThrow('LightFX bake was cancelled and cannot be committed.'); @@ -328,7 +348,7 @@ describe('LightFXBakeHost', () => { const operationId = await finishLightProbe(); const committing = host.commit({ operationId }); - await expect(host.cancel()).resolves.toEqual({ cancelled: false, target: null }); + await expect(host.cancel({ operationId, target: 'light-probe' })).resolves.toEqual({ cancelled: false, target: null }); await expect(committing).resolves.toBeUndefined(); await expect(host.commit({ operationId })).resolves.toBeUndefined(); }); diff --git a/src/core/scene/test/lightfx-bake-renderer.test.ts b/src/core/scene/test/lightfx-bake-renderer.test.ts index edee8e133..96845ff88 100644 --- a/src/core/scene/test/lightfx-bake-renderer.test.ts +++ b/src/core/scene/test/lightfx-bake-renderer.test.ts @@ -130,4 +130,16 @@ describe('LightFX active scene renderer routing', () => { )).rejects.toThrow('visible scene renderer has not finished loading'); expect(fallback).not.toHaveBeenCalled(); }); + + it.each(['LightProbeBake', 'LightmapBake'] as const)('preserves the %s module when routing cancellation', async module => { + const visible = createSocket({ id: 'visible', sceneUrl: 'db://assets/Test.scene', visible: true }); + useSockets([visible]); + const fallback = jest.fn(); + await lightFXBakeRenderer.cancel(module, fallback); + expect(visible.emit).toHaveBeenCalledWith('scene:invoke-lightfx', expect.objectContaining({ module, method: 'cancel' }), expect.any(Function)); + expect(fallback).not.toHaveBeenCalled(); + useSockets([]); + await lightFXBakeRenderer.cancel(module, fallback); + expect(fallback).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/core/scene/test/lightfx-cancel-owner.test.ts b/src/core/scene/test/lightfx-cancel-owner.test.ts new file mode 100644 index 000000000..542811a82 --- /dev/null +++ b/src/core/scene/test/lightfx-cancel-owner.test.ts @@ -0,0 +1,81 @@ +const mockExport = jest.fn(); +jest.mock('cc', () => ({})); +jest.mock('../scene-process/service/baking/lightfx/exporter', () => ({ LightFXExporter: jest.fn(() => ({ export: mockExport })) })); +jest.mock('../scene-process/service/baking/lightfx/format', () => ({ encodeLightFXInput: () => new Uint8Array([1]) })); +jest.mock('../scene-process/service/baking/lightfx/buffer', () => ({ encodeLightFXBase64: () => 'AQ==' })); +jest.mock('../scene-process/service/baking/lightfx/scene-operation', () => ({ lightFXSceneOperation: { hostTransactionId: 'owner' } })); +jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { + begin: jest.fn(), appendInput: jest.fn(), run: jest.fn(), commit: jest.fn(), rollback: jest.fn(), + queryCapabilities: jest.fn(), cancel: jest.fn(), +} })); + +import type { Scene } from 'cc'; +import { LightFXCoordinator } from '../scene-process/service/baking/lightfx/baker'; +import { lightFXBakeHost as host } from '../scene-process/service/baking/lightfx/host'; +import type { LightFXSettings } from '../scene-process/service/baking/lightfx/types'; + +describe('LightFX cancellation ownership', () => { + const scene = { name: 'Test' } as Scene; + const settings = {} as LightFXSettings; + beforeEach(() => { + jest.clearAllMocks(); + for (const method of Object.values(host)) { + if (jest.isMockFunction(method)) method.mockReset(); + } + mockExport.mockReset().mockResolvedValue({ textureSources: [], world: {} }); + jest.mocked(host.begin).mockResolvedValue({ operationId: 'first' }); + jest.mocked(host.rollback).mockResolvedValue(undefined); + jest.mocked(host.run).mockResolvedValue({ result: { version: 1, meshes: [], terrains: [], probes: [] }, textureUrls: [] }); + jest.mocked(host.queryCapabilities).mockResolvedValue({ sceneTransactionVersion: 1, cancelOwnershipVersion: 1, busy: true }); + jest.mocked(host.cancel).mockResolvedValue({ cancelled: true, target: 'light-probe' }); + }); + + it('does not contact the host for another runtime, wrong target or pre-native export', async () => { + const owner = new LightFXCoordinator(), other = new LightFXCoordinator(); + let finish!: (value: object) => void; + mockExport.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const bake = owner.bake(scene, 'light-probe', settings, 1000); + await expect(owner.cancel('light-probe')).resolves.toEqual({ cancelled: false, target: null }); + finish({ textureSources: [], world: {} }); + await bake; + await expect(other.cancel('light-probe')).resolves.toEqual({ cancelled: false, target: null }); + await expect(owner.cancel('lightmap')).resolves.toEqual({ cancelled: false, target: null }); + expect(host.queryCapabilities).not.toHaveBeenCalled(); + expect(host.cancel).not.toHaveBeenCalled(); + await owner.cancel('light-probe'); + expect(host.cancel).toHaveBeenCalledWith({ operationId: 'first', transactionId: 'owner', target: 'light-probe' }); + }); + + it('refuses an older host and never falls back to unscoped cancellation', async () => { + const owner = new LightFXCoordinator(); + await owner.bake(scene, 'light-probe', settings, 1000); + jest.mocked(host.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, busy: true }); + await expect(owner.cancel('light-probe')).rejects.toThrow('ownership protocol'); + expect(host.cancel).not.toHaveBeenCalled(); + }); + + it('a late handshake retains the old operation identity, and completed owners are cleared', async () => { + const owner = new LightFXCoordinator(); + await owner.bake(scene, 'light-probe', settings, 1000); + let finish!: (value: Awaited>) => void; + jest.mocked(host.queryCapabilities).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const cancel = owner.cancel('light-probe'); + await owner.commit('first'); + await expect(owner.cancel('light-probe')).resolves.toEqual({ cancelled: false, target: null }); + jest.mocked(host.begin).mockResolvedValueOnce({ operationId: 'second' }); + await owner.bake(scene, 'light-probe', settings, 1000); + finish({ sceneTransactionVersion: 1, cancelOwnershipVersion: 1, busy: true }); + await cancel; + expect(host.cancel).toHaveBeenCalledWith({ operationId: 'first', transactionId: 'owner', target: 'light-probe' }); + await owner.rollback('second'); + await expect(owner.cancel('light-probe')).resolves.toEqual({ cancelled: false, target: null }); + }); + + it('clears ownership when native execution fails', async () => { + const owner = new LightFXCoordinator(); + jest.mocked(host.run).mockRejectedValueOnce(new Error('cancelled')); + await expect(owner.bake(scene, 'light-probe', settings, 1000)).rejects.toThrow('cancelled'); + await expect(owner.cancel('light-probe')).resolves.toEqual({ cancelled: false, target: null }); + expect(host.rollback).toHaveBeenCalledWith({ operationId: 'first' }); + }); +}); diff --git a/src/core/scene/test/lightfx-scene-entrances.test.ts b/src/core/scene/test/lightfx-scene-entrances.test.ts index 4a18a53f9..da9841ae8 100644 --- a/src/core/scene/test/lightfx-scene-entrances.test.ts +++ b/src/core/scene/test/lightfx-scene-entrances.test.ts @@ -1,6 +1,6 @@ const mockGetScene = jest.fn(); jest.mock('cc', () => ({ director: { getScene: mockGetScene } })); -jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: {} })); +jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { cancel: jest.fn() } })); jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { queryCapabilities: jest.fn(), reserveSceneOperation: jest.fn(async () => ({ transactionId: 'test-owner' })), @@ -14,8 +14,15 @@ import { LightProbeBakeService } from '../scene-process/service/light-probe-bake import { LightmapBakeService } from '../scene-process/service/lightmap-bake'; import { lightFXSceneOperation } from '../scene-process/service/baking/lightfx/scene-operation'; import { lightFXBakeHost } from '../scene-process/service/baking/lightfx/host'; +import { lightFXCoordinator } from '../scene-process/service/baking/lightfx/baker'; describe('LightFX service entrance ownership', () => { + it('passes the actual service target to cancellation', async () => { + await new LightProbeBakeService().cancel(); + expect(lightFXCoordinator.cancel).toHaveBeenLastCalledWith('light-probe'); + await new LightmapBakeService().cancel(); + expect(lightFXCoordinator.cancel).toHaveBeenLastCalledWith('lightmap'); + }); it.each([false, true])('queries Lightmap lifecycle and actual host asset support without reserving (busy=%s)', async busy => { jest.mocked(lightFXBakeHost.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, busy }); const reserveCalls = jest.mocked(lightFXBakeHost.reserveSceneOperation).mock.calls.length; diff --git a/tests/lightfx-bake-api.test.ts b/tests/lightfx-bake-api.test.ts index 94976a6ab..d8b719200 100644 --- a/tests/lightfx-bake-api.test.ts +++ b/tests/lightfx-bake-api.test.ts @@ -7,12 +7,26 @@ import { } from '../src/api/scene/lightfx-bake-schema'; const probeBake = jest.fn(); const lightmapBake = jest.fn(); const queryLightmapBakeInfo = jest.fn(); +const probeCancel = jest.fn(); const lightmapCancel = jest.fn(); jest.mock('../src/api/decorator/decorator', () => ({ description: () => jest.fn(), param: () => jest.fn(), result: () => jest.fn(), title: () => jest.fn(), tool: () => jest.fn() })); -jest.mock('../src/core/scene', () => ({ Scene: { LightProbeBake: { bake: (...args: unknown[]) => probeBake(...args), clearBake: jest.fn(), cancel: jest.fn() }, LightmapBake: { bake: (...args: unknown[]) => lightmapBake(...args), queryBakeInfo: (...args: unknown[]) => queryLightmapBakeInfo(...args), clearBake: jest.fn(), cancel: jest.fn() } } })); +jest.mock('../src/core/scene', () => ({ Scene: { LightProbeBake: { bake: (...args: unknown[]) => probeBake(...args), clearBake: jest.fn(), cancel: () => probeCancel() }, LightmapBake: { bake: (...args: unknown[]) => lightmapBake(...args), queryBakeInfo: (...args: unknown[]) => queryLightmapBakeInfo(...args), clearBake: jest.fn(), cancel: () => lightmapCancel() } } })); import { LightFXBakeApi } from '../src/api/scene/lightfx-bake'; describe('LightFX bake API', () => { - beforeEach(() => { probeBake.mockReset(); lightmapBake.mockReset(); queryLightmapBakeInfo.mockReset(); }); + beforeEach(() => { probeBake.mockReset(); lightmapBake.mockReset(); queryLightmapBakeInfo.mockReset(); probeCancel.mockReset(); lightmapCancel.mockReset(); }); + it.each([true, false])('tries Lightmap cancel only when Probe did not cancel (probe=%s)', async cancelled => { + const probe = { cancelled, target: cancelled ? 'light-probe' : null }; + const lightmap = { cancelled: true, target: 'lightmap' }; + probeCancel.mockResolvedValue(probe); + lightmapCancel.mockResolvedValue(lightmap); + await expect(new LightFXBakeApi().cancel()).resolves.toEqual({ code: COMMON_STATUS.SUCCESS, data: cancelled ? probe : lightmap }); + expect(lightmapCancel).toHaveBeenCalledTimes(cancelled ? 0 : 1); + }); + it('does not cancel another type after an uncertain cancellation error', async () => { + probeCancel.mockRejectedValue(new Error('Disconnected')); + await expect(new LightFXBakeApi().cancel()).resolves.toEqual({ code: COMMON_STATUS.FAIL, reason: 'Disconnected' }); + expect(lightmapCancel).not.toHaveBeenCalled(); + }); it('validates all Creator light-probe panel parameters', () => { const options = { giScale: 8, giSamples: 4096, bounces: 1, reduceRinging: 0.02, From 54664c81572ea04713e2bfbc4c1904f430a65f4e Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 16:32:45 +0800 Subject: [PATCH 21/64] =?UTF-8?q?feat(scene):=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E5=85=89=E7=85=A7=E6=8E=A2=E9=92=88=E5=8F=96=E6=B6=88=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E4=B8=8E=E5=B0=B1=E7=BB=AA=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 2 ++ src/core/scene/common/lightfx-bake.ts | 4 ++++ .../scene/scene-process/service/baking/lightfx/baker.ts | 2 ++ src/core/scene/scene-process/service/light-probe-bake.ts | 3 ++- src/core/scene/test/lightfx-cancel-owner.test.ts | 5 +++++ src/core/scene/test/lightfx-scene-entrances.test.ts | 9 ++++++++- 6 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index aa0cd71b4..ae73888b9 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -290,6 +290,8 @@ Pink 应在场景打开、烘焙完成和清理完成后调用该工具刷新面 Scene 侧 `LightProbeBake.cancel()`/`LightmapBake.cancel()` 只取消本 renderer 内对应类型的 Bake,不再取消共享 host 上其他场景/其他类型的任务。内部请求须带精确 operation ID、目标和 scene transaction ID;缺失/过期归属返回 `cancelled:false`,旧 host 缺少取消归属协议时拒绝请求,不回退到全局取消。导出阶段或 native begin 尚未返回 ID 时也返回 false;因此 false 不一定表示没有烘焙,而是这次请求没有取消任务。 +`LightProbeBake.queryCapabilities()` 仅在实际 host 支持上述归属协议时额外返回 `cancelVersion:1` 和 `cancellable`,不支持时省略。`cancellable` 仅在本 Scene 的原生 Probe operation 已取得 ID 时为 true;准备阶段为 false,供 UI 据实启用按钮,执行时仍核对精确归属。该能力不代表持久任务快照;UI 可用自身的 renderer 会话 ID 保护取消消息,再调用该 Scene 的取消入口,任务结束仍以原 Bake Promise 完成回滚为准。 + 通用 MCP 工具保留按 Probe/Lightmap 依次尝试的行为,主进程已跟踪的 Bake 仍路由到原 renderer,不因切标签改投另一个场景。它不是跨客户端认证或公共持久任务句柄;需要严格防止客户端旧消息取消后续任务的 UI,仍须先接入独立任务身份契约。 取消成功后,取消工具本身返回 `code: 200`;原烘焙请求结束并返回 `code: 500`、`reason: "LightFX bake was cancelled."`。这是被取消任务的预期终态。 diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts index cdbf500d3..c40366690 100644 --- a/src/core/scene/common/lightfx-bake.ts +++ b/src/core/scene/common/lightfx-bake.ts @@ -16,6 +16,10 @@ export interface ILightProbeBakeOptions { /** Versioned implementation support, not native executable readiness or a recoverable task. */ export interface ILightProbeBakeCapabilities { version: 1; + /** Same-Scene probe cancellation verifies the actual host's native operation ownership. */ + cancelVersion?: 1; + /** Advisory readiness of this Scene's native probe operation; absent on older implementations. */ + cancellable?: boolean; /** SH Undo/Redo and multi-group scene reopening preserve baked results. */ resultLifecycleVersion: 1; /** Both Scene and host participate in the full Bake/Clear transaction reservation. */ diff --git a/src/core/scene/scene-process/service/baking/lightfx/baker.ts b/src/core/scene/scene-process/service/baking/lightfx/baker.ts index ee4ba1a28..a8902dac1 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/baker.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/baker.ts @@ -21,6 +21,8 @@ export class LightFXCoordinator { get activeTarget(): LightFXBakeTarget | null { return this.target; } + canCancel(target: LightFXBakeTarget): boolean { return this.operation?.target === target; } + async bake(scene: Scene, target: LightFXBakeTarget, settings: LightFXSettings, timeoutMs: number): Promise { if (this.target) throw new Error(`A ${this.target} LightFX bake is already in progress.`); this.target = target; diff --git a/src/core/scene/scene-process/service/light-probe-bake.ts b/src/core/scene/scene-process/service/light-probe-bake.ts index 2694a7e91..40ac1fe53 100644 --- a/src/core/scene/scene-process/service/light-probe-bake.ts +++ b/src/core/scene/scene-process/service/light-probe-bake.ts @@ -36,7 +36,8 @@ export class LightProbeBakeService extends BaseService imple if (host?.sceneTransactionVersion !== 1 || typeof host.busy !== 'boolean') { throw new Error('The LightFX host does not support scene transaction protocol version 1.'); } - return { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, busy: host.busy }; + return { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, + ...(host.cancelOwnershipVersion === 1 ? { cancelVersion: 1 as const, cancellable: lightFXCoordinator.canCancel('light-probe') } : {}), busy: host.busy }; } async bake(options: ILightProbeBakeOptions = {}): Promise { diff --git a/src/core/scene/test/lightfx-cancel-owner.test.ts b/src/core/scene/test/lightfx-cancel-owner.test.ts index 542811a82..cc124c033 100644 --- a/src/core/scene/test/lightfx-cancel-owner.test.ts +++ b/src/core/scene/test/lightfx-cancel-owner.test.ts @@ -35,9 +35,13 @@ describe('LightFX cancellation ownership', () => { let finish!: (value: object) => void; mockExport.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); const bake = owner.bake(scene, 'light-probe', settings, 1000); + expect(owner.canCancel('light-probe')).toBe(false); await expect(owner.cancel('light-probe')).resolves.toEqual({ cancelled: false, target: null }); finish({ textureSources: [], world: {} }); await bake; + expect(owner.canCancel('light-probe')).toBe(true); + expect(owner.canCancel('lightmap')).toBe(false); + expect(other.canCancel('light-probe')).toBe(false); await expect(other.cancel('light-probe')).resolves.toEqual({ cancelled: false, target: null }); await expect(owner.cancel('lightmap')).resolves.toEqual({ cancelled: false, target: null }); expect(host.queryCapabilities).not.toHaveBeenCalled(); @@ -61,6 +65,7 @@ describe('LightFX cancellation ownership', () => { jest.mocked(host.queryCapabilities).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); const cancel = owner.cancel('light-probe'); await owner.commit('first'); + expect(owner.canCancel('light-probe')).toBe(false); await expect(owner.cancel('light-probe')).resolves.toEqual({ cancelled: false, target: null }); jest.mocked(host.begin).mockResolvedValueOnce({ operationId: 'second' }); await owner.bake(scene, 'light-probe', settings, 1000); diff --git a/src/core/scene/test/lightfx-scene-entrances.test.ts b/src/core/scene/test/lightfx-scene-entrances.test.ts index da9841ae8..da9222a5a 100644 --- a/src/core/scene/test/lightfx-scene-entrances.test.ts +++ b/src/core/scene/test/lightfx-scene-entrances.test.ts @@ -1,6 +1,6 @@ const mockGetScene = jest.fn(); jest.mock('cc', () => ({ director: { getScene: mockGetScene } })); -jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { cancel: jest.fn() } })); +jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { cancel: jest.fn(), canCancel: jest.fn(() => false) } })); jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { queryCapabilities: jest.fn(), reserveSceneOperation: jest.fn(async () => ({ transactionId: 'test-owner' })), @@ -17,6 +17,13 @@ import { lightFXBakeHost } from '../scene-process/service/baking/lightfx/host'; import { lightFXCoordinator } from '../scene-process/service/baking/lightfx/baker'; describe('LightFX service entrance ownership', () => { + it.each([false, true])('advertises actual probe cancellation readiness (%s)', async cancellable => { + jest.mocked(lightFXCoordinator.canCancel).mockReturnValueOnce(cancellable); + jest.mocked(lightFXBakeHost.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, cancelOwnershipVersion: 1, busy: true }); + await expect(new LightProbeBakeService().queryCapabilities()).resolves.toEqual({ + version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, cancelVersion: 1, cancellable, busy: true, + }); + }); it('passes the actual service target to cancellation', async () => { await new LightProbeBakeService().cancel(); expect(lightFXCoordinator.cancel).toHaveBeenLastCalledWith('light-probe'); From c7dc8bc94e9542cb45f3959b4bbe2eabe30c7e55 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 16:46:56 +0800 Subject: [PATCH 22/64] =?UTF-8?q?feat(scene):=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E5=85=89=E7=85=A7=E8=B4=B4=E5=9B=BE=E5=8F=96=E6=B6=88=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E4=B8=8E=E5=B0=B1=E7=BB=AA=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 2 +- src/core/scene/common/lightfx-bake.ts | 4 ++++ src/core/scene/scene-process/service/lightmap-bake.ts | 3 ++- src/core/scene/test/lightfx-scene-entrances.test.ts | 8 ++++++++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index ae73888b9..eccb0531b 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -290,7 +290,7 @@ Pink 应在场景打开、烘焙完成和清理完成后调用该工具刷新面 Scene 侧 `LightProbeBake.cancel()`/`LightmapBake.cancel()` 只取消本 renderer 内对应类型的 Bake,不再取消共享 host 上其他场景/其他类型的任务。内部请求须带精确 operation ID、目标和 scene transaction ID;缺失/过期归属返回 `cancelled:false`,旧 host 缺少取消归属协议时拒绝请求,不回退到全局取消。导出阶段或 native begin 尚未返回 ID 时也返回 false;因此 false 不一定表示没有烘焙,而是这次请求没有取消任务。 -`LightProbeBake.queryCapabilities()` 仅在实际 host 支持上述归属协议时额外返回 `cancelVersion:1` 和 `cancellable`,不支持时省略。`cancellable` 仅在本 Scene 的原生 Probe operation 已取得 ID 时为 true;准备阶段为 false,供 UI 据实启用按钮,执行时仍核对精确归属。该能力不代表持久任务快照;UI 可用自身的 renderer 会话 ID 保护取消消息,再调用该 Scene 的取消入口,任务结束仍以原 Bake Promise 完成回滚为准。 +`LightProbeBake.queryCapabilities()`/`LightmapBake.queryCapabilities()` 仅在实际 host 支持上述归属协议时额外返回 `cancelVersion:1` 和 `cancellable`,不支持时省略。`cancellable` 仅在本 Scene 对应类型的原生 operation 已取得 ID 时为 true;准备阶段为 false,供 UI 据实启用按钮,执行时仍核对精确归属。就绪快照不保证取消一定先于 commit,已提交的任务仍返回 false。该能力不代表持久任务快照;UI 可用自身的 renderer 会话 ID 保护取消消息,再调用该 Scene 的取消入口,任务结束仍以原 Bake Promise 完成回滚为准。 通用 MCP 工具保留按 Probe/Lightmap 依次尝试的行为,主进程已跟踪的 Bake 仍路由到原 renderer,不因切标签改投另一个场景。它不是跨客户端认证或公共持久任务句柄;需要严格防止客户端旧消息取消后续任务的 UI,仍须先接入独立任务身份契约。 diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts index c40366690..e7ad8c287 100644 --- a/src/core/scene/common/lightfx-bake.ts +++ b/src/core/scene/common/lightfx-bake.ts @@ -66,6 +66,10 @@ export interface ILightmapBakeCapabilities { sceneTransactionVersion: 1; /** The actual host preserves previous textures in immutable per-operation directories. */ assetVersion: 1; + /** Same-Scene cancellation requires the actual host ownership protocol. */ + cancelVersion?: 1; + /** Advisory: this Scene has obtained a native Lightmap operation ID. */ + cancellable?: boolean; busy: boolean; } diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index d59f47e94..6ee42c957 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -27,7 +27,8 @@ export class LightmapBakeService extends BaseService impleme if (host?.sceneTransactionVersion !== 1 || host.lightmapAssetVersion !== 1 || typeof host.busy !== 'boolean') { throw new Error('The LightFX host does not support scene transaction and immutable Lightmap asset protocol version 1.'); } - return { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, busy: host.busy }; + return { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, + ...(host.cancelOwnershipVersion === 1 ? { cancelVersion: 1 as const, cancellable: lightFXCoordinator.canCancel('lightmap') } : {}), busy: host.busy }; } async bake(options: ILightmapBakeOptions = {}): Promise { diff --git a/src/core/scene/test/lightfx-scene-entrances.test.ts b/src/core/scene/test/lightfx-scene-entrances.test.ts index da9222a5a..a1a4d7e05 100644 --- a/src/core/scene/test/lightfx-scene-entrances.test.ts +++ b/src/core/scene/test/lightfx-scene-entrances.test.ts @@ -17,6 +17,14 @@ import { lightFXBakeHost } from '../scene-process/service/baking/lightfx/host'; import { lightFXCoordinator } from '../scene-process/service/baking/lightfx/baker'; describe('LightFX service entrance ownership', () => { + it.each([false, true])('advertises actual Lightmap cancellation readiness (%s)', async cancellable => { + jest.mocked(lightFXCoordinator.canCancel).mockReturnValueOnce(cancellable); + jest.mocked(lightFXBakeHost.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, busy: true }); + await expect(new LightmapBakeService().queryCapabilities()).resolves.toEqual({ + version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, cancelVersion: 1, cancellable, busy: true, + }); + expect(lightFXCoordinator.canCancel).toHaveBeenLastCalledWith('lightmap'); + }); it.each([false, true])('advertises actual probe cancellation readiness (%s)', async cancellable => { jest.mocked(lightFXCoordinator.canCancel).mockReturnValueOnce(cancellable); jest.mocked(lightFXBakeHost.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, cancelOwnershipVersion: 1, busy: true }); From b2ea9b2cf0208b0a9880a729705894c14717b58d Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 17:18:14 +0800 Subject: [PATCH 23/64] =?UTF-8?q?feat(scene):=20=E6=95=B4=E5=90=88?= =?UTF-8?q?=E6=8E=A2=E9=92=88=E7=BC=96=E8=BE=91=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=B7=A8=E7=BB=84=E9=80=89=E6=8B=A9=E4=B8=8E=E5=9D=90=E6=A0=87?= =?UTF-8?q?=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/scene/common/gizmo.ts | 18 +- src/core/scene/common/message.ts | 2 + src/core/scene/scene-process/service/gizmo.ts | 28 ++ .../components/light-probe-group/index.ts | 244 +++++++++++++++++- .../components/light-probe-group/selection.ts | 36 +++ .../service/gizmo/gizmo-operation.ts | 51 +++- .../scene-process/service/service-manager.ts | 2 + .../scene/test/light-probe-edit-gizmo.test.ts | 108 ++++++++ .../scene/test/light-probe-selection.test.ts | 56 ++++ 9 files changed, 535 insertions(+), 10 deletions(-) create mode 100644 src/core/scene/scene-process/service/gizmo/components/light-probe-group/selection.ts create mode 100644 src/core/scene/test/light-probe-edit-gizmo.test.ts create mode 100644 src/core/scene/test/light-probe-selection.test.ts diff --git a/src/core/scene/common/gizmo.ts b/src/core/scene/common/gizmo.ts index cfe8b2335..878e3cb44 100644 --- a/src/core/scene/common/gizmo.ts +++ b/src/core/scene/common/gizmo.ts @@ -63,6 +63,18 @@ export interface IGizmoService { showSelectionRegion(left: number, right: number, top: number, bottom: number): void; hideSelectionRegion(): void; execGizmoMethods(name: string, funcName: string, params?: any[]): any; + /** Toggles the current selected probe groups' vertex mode. */ + toggleLightProbeEditMode(enabled: boolean): boolean; + queryLightProbeEditMode(): boolean; + toggleLightProbeBoundingBoxEditMode(enabled: boolean): boolean; + queryLightProbeBoundingBoxEditMode(): boolean; + selectAllLightProbes(): void; + unselectAllLightProbes(): void; + queryLightProbeSelectedCount(): number; + duplicateSelectedLightProbes(): Promise; + deleteSelectedLightProbes(): Promise; + generateLightProbes(): number; + regionSelectLightProbes(left: number, right: number, top: number, bottom: number, additive: boolean): number; } export type IPublicGizmoService = Pick; export interface IGizmoEvents { diff --git a/src/core/scene/common/message.ts b/src/core/scene/common/message.ts index 778554f89..942708d60 100644 --- a/src/core/scene/common/message.ts +++ b/src/core/scene/common/message.ts @@ -15,6 +15,8 @@ import type { IReferenceImageEvents } from './reference-image'; * messageManager 不在已有接口中的补充事件 */ export interface ISceneEvents { + 'scene:light-probe-edit-mode-changed': [enabled: boolean]; + 'scene:light-probe-bounding-box-edit-mode-changed': [enabled: boolean]; 'scene:dimension-changed': [is2D: boolean]; } diff --git a/src/core/scene/scene-process/service/gizmo.ts b/src/core/scene/scene-process/service/gizmo.ts index b10ebcf1e..2b649ea70 100644 --- a/src/core/scene/scene-process/service/gizmo.ts +++ b/src/core/scene/scene-process/service/gizmo.ts @@ -1164,6 +1164,34 @@ export class GizmoService extends BaseService implements IGizmoSer return methods[funcName](...params); } + toggleLightProbeEditMode(enabled: boolean): boolean { + this.execGizmoMethods('cc.LightProbeGroup', 'changeEditMode', [enabled && !this.queryLightProbeEditMode() ? 'vertex' : 'none']); + return this.queryLightProbeEditMode(); + } + + queryLightProbeEditMode(): boolean { + return this.execGizmoMethods('cc.LightProbeGroup', 'getEditMode') === 'vertex'; + } + + toggleLightProbeBoundingBoxEditMode(enabled: boolean): boolean { + this.execGizmoMethods('cc.LightProbeGroup', 'changeEditMode', [enabled && !this.queryLightProbeBoundingBoxEditMode() ? 'box' : 'none']); + return this.queryLightProbeBoundingBoxEditMode(); + } + + queryLightProbeBoundingBoxEditMode(): boolean { + return this.execGizmoMethods('cc.LightProbeGroup', 'getEditMode') === 'box'; + } + + selectAllLightProbes(): void { this.execGizmoMethods('cc.LightProbeGroup', 'selectAllProbes'); } + unselectAllLightProbes(): void { this.execGizmoMethods('cc.LightProbeGroup', 'unselectAllProbes'); } + queryLightProbeSelectedCount(): number { return this.execGizmoMethods('cc.LightProbeGroup', 'getSelectedProbeCount') ?? 0; } + async duplicateSelectedLightProbes(): Promise { return await this.execGizmoMethods('cc.LightProbeGroup', 'duplicateSelectedProbes') ?? 0; } + async deleteSelectedLightProbes(): Promise { return await this.execGizmoMethods('cc.LightProbeGroup', 'deleteSelectedProbes') ?? 0; } + generateLightProbes(): number { return this.execGizmoMethods('cc.LightProbeGroup', 'generateLightProbes') ?? 0; } + regionSelectLightProbes(left: number, right: number, top: number, bottom: number, additive: boolean): number { + return this.execGizmoMethods('cc.LightProbeGroup', 'regionSelectProbes', [left, right, top, bottom, additive]) ?? 0; + } + _changeRegionSelectMode(mode: number): void { (GizmoOperation as any).changeRegionSelectMode?.(mode); } diff --git a/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts b/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts index 1ed3bb7a5..c78d0ded0 100644 --- a/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts +++ b/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts @@ -8,11 +8,18 @@ import ControllerUtils from '../../utils/controller-utils'; import { addMeshToNode, create3DNode, getModel, setMeshColor } from '../../utils/engine-utils'; import { registerGizmo } from '../../gizmo-defines'; import { buildLightProbeConvex } from '../../utils/light-probe-convex'; +import PositionController from '../../node/position-controller'; +import { Service } from '../../../core/decorator'; +import { ServiceEvents } from '../../../core/global-events'; +import { ProbeSelection, probeSelectionEvents } from './selection'; +import type { GizmoMouseEvent } from '../../utils/defines'; +import type { CameraService } from '../../../camera'; // 探针数量超过该阈值时只画包围盒/线框、不逐个建球,避免海量节点 const MAX_PROBE_DOTS = 4096; // 对齐 Cocos Creator LightProbeController 常量 const PROBE_COLOR = new Color(241, 163, 72); // #F1A348 +const SELECTED_COLOR = new Color(64, 170, 202); const WIREFRAME_COLOR = new Color(252, 231, 196); // #FCE7C4 const PROBE_SPHERE_BASE_RADIUS = 5; // 内部四面体 6 条边 @@ -21,6 +28,75 @@ const TETRAHEDRON_LINES = [0, 1, 0, 2, 0, 3, 1, 2, 1, 3, 2, 3]; const tempQuat_a = new Quat(); const tempDelta = new Vec3(); +type EditMode = 'none' | 'vertex' | 'box'; +let editMode: EditMode = 'none'; +let previousTool: string | undefined; +const instances = new Set(); + +function activeGroups(): LightProbeGroupComponentGizmo[] { + return [...instances].filter(gizmo => gizmo.editableTarget); +} + +function changeEditMode(mode: EditMode): void { + if (!['none', 'vertex', 'box'].includes(mode)) { throw new Error('Invalid light probe edit mode'); } + if (mode === editMode) { return; } + editMode = mode; + const gizmo = Service.Gizmo; + if (mode === 'vertex') { + previousTool = gizmo.transformToolName; + gizmo.transformToolData.viewMode = 'select'; + gizmo.transformToolName = 'view'; + } else if (previousTool !== undefined) { + const restore = previousTool; + previousTool = undefined; + gizmo.transformToolName = restore; + } + for (const instance of instances) { instance.modeChanged(); } + ServiceEvents.broadcast('scene:light-probe-edit-mode-changed', mode === 'vertex'); + ServiceEvents.broadcast('scene:light-probe-bounding-box-edit-mode-changed', mode === 'box'); + Service.Engine.repaintInEditMode(); +} + +async function editSelected(duplicate: boolean): Promise { + const groups = activeGroups().filter(group => editMode === 'vertex' && group.selection.indices.size > 0); + if (!groups.length) { return 0; } + const recording = Service.Undo.beginRecording(groups.map(group => group.target!.node.uuid)); + try { + return groups.reduce((count, group) => count + group.editSelected(duplicate), 0); + } finally { + await Service.Undo.endRecording(recording); + } +} + +export const methods = { + getEditMode: (): EditMode => editMode, + changeEditMode, + selectAllProbes: () => { + for (const group of activeGroups()) { + if (editMode === 'vertex') { group.selection.all(); group.refreshSelection(); } + } + }, + unselectAllProbes: () => { + for (const group of instances) { group.selection.indices.clear(); group.refreshSelection(); } + }, + getSelectedProbeCount: () => editMode === 'vertex' + ? activeGroups().reduce((count, group) => count + group.selection.indices.size, 0) : 0, + deleteSelectedProbes: () => editSelected(false), + duplicateSelectedProbes: () => editSelected(true), + generateLightProbes: () => { + const groups = activeGroups(); + for (const group of groups) { group.target!.generateLightProbes(); group.probesChanged(); } + return groups.length; + }, + beginRegion: () => { for (const group of activeGroups()) { group.selection.beginRegion(); } }, + endRegion: () => { for (const group of instances) { group.selection.endRegion(); } }, + regionSelectProbes: (left: number, right: number, top: number, bottom: number, additive: boolean) => { + if (![left, right, top, bottom].every(Number.isFinite) || editMode !== 'vertex') { return 0; } + for (const group of activeGroups()) { group.regionSelect(left, right, top, bottom, additive); } + return methods.getSelectedProbeCount(); + }, +}; + /** * 光照探针组(LightProbeGroup)选中 Gizmo — 对齐 Cocos Creator: * - 全部探针小球(#F1A348,世界固定尺寸); @@ -28,6 +104,21 @@ const tempDelta = new Vec3(); * - 绿色生成包围盒,支持逐面非对称拖拽(改 minPos/maxPos),松手重生成探针。 */ class LightProbeGroupComponentGizmo extends GizmoBase { + readonly selection = new ProbeSelection(); + private shown = false; + private boundTarget: LightProbeGroup | null = null; + private positionController: PositionController | null = null; + private dragStart: Map | undefined; + + override get target(): LightProbeGroup | null { return super.target; } + override set target(value: LightProbeGroup | null) { + if (super.target !== value) { this.finishDrag(); this.selection.bind(null, 0); } + super.target = value; + } + + get editableTarget(): boolean { + return this.shown && !!this.target?.isValid && this.target.enabledInHierarchy; + } private _controller!: BoxController; private _dotsRoot: Node | null = null; // 探针球容器(跟随节点世界变换) private _wireframeNode: Node | null = null; // 四面体线框(世界坐标、单位阵) @@ -46,22 +137,131 @@ class LightProbeGroupComponentGizmo extends GizmoBase { private _maxPropPath: string | null = null; init() { + instances.add(this); this.createController(); this._isInitialized = true; } onShow() { - this._controller.show(); + this.shown = true; this.updateControllerData(); } onHide() { + this.finishDrag(); + this.shown = false; + this.selection.bind(null, 0); + this.positionController?.hide(); this._controller.hide(); if (this._dotsRoot) this._dotsRoot.active = false; if (this._wireframeNode) this._wireframeNode.active = false; if (this._convexNode) this._convexNode.active = false; if (this._normalNode) this._normalNode.active = false; this._lastInfoSig = ''; + if (!activeGroups().length) { changeEditMode('none'); } + } + + modeChanged(): void { + this.finishDrag(); + this.selection.indices.clear(); + this.positionController?.hide(); + this.updateControllerData(); + } + + refreshSelection(): void { + for (const [index, dot] of (this._dotsRoot?.children ?? []).entries()) { + setMeshColor(dot, this.selection.indices.has(index) ? SELECTED_COLOR : PROBE_COLOR); + } + if (!this.editableTarget || editMode !== 'vertex' || !this.selection.indices.size || !this._dotsRoot?.active) { + this.positionController?.hide(); + return; + } + if (!this.positionController) { + this.positionController = new PositionController(this.getGizmoRoot()); + this.positionController.onControllerMouseDown = () => { + if (!this.target) { return; } + this.dragStart = new Map([...this.selection.indices].map(index => [index, Vec3.clone(this.target!.probes[index])])); + this.onControlBegin(this.getCompPropPath('probes')); + }; + this.positionController.onControllerMouseMove = () => { + if (!this.target || !this.dragStart) { return; } + const delta = this.positionController!.getDeltaPosition(); + const probes = this.target.probes.slice(); + for (const [index, start] of this.dragStart) { probes[index] = Vec3.add(new Vec3(), start, delta); } + this.target.probes = probes; + for (const [index, dot] of this._dotsRoot!.children.entries()) { dot.setPosition(probes[index]); } + Service.Engine.repaintInEditMode(); + }; + this.positionController.onControllerMouseUp = () => this.finishDrag(); + } + if (this.dragStart) { return; } + const center = new Vec3(); + for (const index of this.selection.indices) { center.add(this.target!.probes[index]); } + center.multiplyScalar(1 / this.selection.indices.size).add(this.target!.node.worldPosition); + this.positionController.setPosition(center); + this.positionController.setRotation(Quat.IDENTITY); + this.positionController.show(); + Service.Engine.repaintInEditMode(); + } + + private finishDrag(): void { + if (!this.dragStart) { return; } + const moved = !!this.target && [...this.dragStart].some(([index, start]) => !Vec3.strictEquals(start, this.target!.probes[index])); + this.dragStart = undefined; + if (moved) { this.probesChanged(); } + void this.onControlEnd(this.getCompPropPath('probes')); + } + + probesChanged(): void { + if (!this.target) { return; } + this.target.onProbeChanged(); + this.target.node.scene.globals.lightProbeInfo.onProbeBakeCleared(); + this.selection.bind(this.target, this.target.probes.length); + this._probesRef = null; + this.updateControllerData(); + this.onComponentChanged(this.target.node); + } + + editSelected(duplicate: boolean): number { + if (!this.editableTarget || !this.target || editMode !== 'vertex') { return 0; } + const indices = [...this.selection.indices]; + const probes = this.target.probes; + this.target.probes = duplicate ? [...probes, ...indices.map(index => Vec3.clone(probes[index]))] + : probes.filter((_, index) => !this.selection.indices.has(index)); + this.probesChanged(); + if (duplicate) { + indices.forEach((_, index) => this.selection.indices.add(probes.length + index)); + this.refreshSelection(); + } + return indices.length; + } + + regionSelect(left: number, right: number, top: number, bottom: number, additive: boolean): void { + const camera = (Service.Camera as CameraService).getCamera()?.camera; + if (!camera || !this.target || !this._dotsRoot?.active) { return; } + const screen = new Vec3(); + const world = new Vec3(); + const hits: number[] = []; + this.target.probes.forEach((probe, index) => { + Vec3.add(world, probe, this.target!.node.worldPosition); + camera.worldToScreen(screen, world); + if (screen.z >= 0 && screen.z <= 1 && screen.x >= left && screen.x <= right && screen.y >= bottom && screen.y <= top) { + hits.push(index); + } + }); + this.selection.region(hits, additive); + this.refreshSelection(); + } + + onKeyDown(event: { key?: string; ctrlKey?: boolean; metaKey?: boolean }): boolean | void { + if (!this.editableTarget || editMode !== 'vertex') { return; } + const key = event.key?.toLowerCase(); + if (key === 'escape') { changeEditMode('none'); return false; } + if ((event.ctrlKey || event.metaKey) && key === 'a') { methods.selectAllProbes(); return false; } + if (key === 'delete' || key === 'backspace' || ((event.ctrlKey || event.metaKey) && key === 'd')) { + void editSelected(key === 'd').catch(error => console.error('[LightProbe] Edit failed', error)); + return false; + } } createController() { @@ -93,7 +293,7 @@ class LightProbeGroupComponentGizmo extends GizmoBase { if (!this._isInitialized || this.target === null) return; this._minPos.set(this.target.minPos); this._maxPos.set(this.target.maxPos); - this._scale = this.target.node.getWorldScale(); + this._scale.set(1, 1, 1); this._minPropPath = this.getCompPropPath('minPos'); this._maxPropPath = this.getCompPropPath('maxPos'); } @@ -106,6 +306,7 @@ class LightProbeGroupComponentGizmo extends GizmoBase { if (this.target && this._controller.updated) { // 依据新范围重生成探针,并刷新探针球/线框 this.target.generateLightProbes(); + this.probesChanged(); this._rebuildDots(true); this._rebuildWireframe(); this._rebuildConvex(); @@ -156,7 +357,13 @@ class LightProbeGroupComponentGizmo extends GizmoBase { } updateControllerData() { - if (!this._isInitialized || this.target == null) return; + if (!this._isInitialized || !this.shown || this.target == null) return; + if (this.boundTarget !== this.target) { + this.boundTarget = this.target; + this.selection.bind(this.target, this.target.probes.length); + this._probesRef = null; + } + this.selection.bind(this.target, this.target.probes.length); if (!(this.target instanceof LightProbeGroup)) { this._controller.hide(); if (this._dotsRoot) this._dotsRoot.active = false; @@ -167,13 +374,14 @@ class LightProbeGroupComponentGizmo extends GizmoBase { } const node = this.target.node; - const worldScale = node.getWorldScale(); + // Match LightProbeInfo.update: samples are offsets from worldPosition, not full TRS. + const worldScale = Vec3.ONE; const worldPos = node.getWorldPosition(); const worldRot = tempQuat_a; - node.getWorldRotation(worldRot); + worldRot.set(Quat.IDENTITY); // 生成包围盒 - this._controller.show(); + if (editMode === 'box') { this._controller.show(); } else { this._controller.hide(); } this._controller.checkEdit(); this._controller.setScale(worldScale); this._controller.setPosition(worldPos); @@ -193,6 +401,7 @@ class LightProbeGroupComponentGizmo extends GizmoBase { this._rebuildDots(false); this._rebuildWireframe(); this._rebuildConvex(); + this.refreshSelection(); } private _getLightProbeInfo(): any { @@ -214,7 +423,7 @@ class LightProbeGroupComponentGizmo extends GizmoBase { this._probesRef = probes; this._dotsVolume = volume; - this._dotsRoot.removeAllChildren(); + for (const dot of [...this._dotsRoot.children]) { dot.removeFromParent(); dot.destroy(); } if (!probes || probes.length === 0 || probes.length > MAX_PROBE_DOTS) return; const scale = volume * 0.06; @@ -232,6 +441,14 @@ class LightProbeGroupComponentGizmo extends GizmoBase { dot.parent = this._dotsRoot; dot.setPosition(probes[i]); dot.setScale(scale, scale, scale); + dot.on('mouseDown', (event: GizmoMouseEvent) => { + if (!this.editableTarget || editMode !== 'vertex' || !event.leftButton) { return; } + if (!event.ctrlKey && !event.metaKey && !event.shiftKey) { methods.unselectAllProbes(); } + if (this.selection.indices.has(i)) { this.selection.indices.delete(i); } else { this.selection.indices.add(i); } + this.refreshSelection(); + probeSelectionEvents.add(event); + event.propagationStopped = true; + }); } } @@ -320,9 +537,11 @@ class LightProbeGroupComponentGizmo extends GizmoBase { // lightProbeInfo 的显示设置/探针数据可能在没有节点变化时改变(如烘焙、面板开关、球体积)。 // 每帧只做一次廉价签名比较,变化时才刷新,避免每帧重建。 onUpdate() { + if (!this.shown || this.dragStart) { return; } const sig = this._computeInfoSig(); if (sig === this._lastInfoSig) return; this._lastInfoSig = sig; + this._probesRef = null; this.updateControllerData(); } @@ -331,6 +550,8 @@ class LightProbeGroupComponentGizmo extends GizmoBase { const data = info?.data; const probes = this.target?.probes; return [ + probes?.map(probe => `${probe.x},${probe.y},${probe.z}`).join(';'), + this.target?.node.worldPosition.toString(), probes ? probes.length : 0, info ? (info.lightProbeSphereVolume ?? 1) : 1, info ? (info.showProbe ?? true) : true, @@ -342,6 +563,13 @@ class LightProbeGroupComponentGizmo extends GizmoBase { } onDestroy() { + this.finishDrag(); + instances.delete(this); + this.selection.bind(null, 0); + this.positionController?.hide(); + this.positionController?.shape.destroy(); + this.positionController = null; + this._controller?.shape.destroy(); this._convexNode?.destroy(); this._convexNode = null; this._normalNode?.destroy(); @@ -372,4 +600,4 @@ export const SelectGizmo = LightProbeGroupComponentGizmo; export const IconGizmo = LightProbeGroupIconGizmo; export const PersistentGizmo = null; -registerGizmo(name, { SelectGizmo, IconGizmo }); +registerGizmo(name, { SelectGizmo, IconGizmo, methods }); diff --git a/src/core/scene/scene-process/service/gizmo/components/light-probe-group/selection.ts b/src/core/scene/scene-process/service/gizmo/components/light-probe-group/selection.ts new file mode 100644 index 000000000..1c2d03b8e --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/light-probe-group/selection.ts @@ -0,0 +1,36 @@ +/** Distinguishes probe sphere hits from transform handles without extending general mouse events. */ +export const probeSelectionEvents = new WeakSet(); + +/** Probe selection belongs to a particular component, never to a reusable Gizmo slot. */ +export class ProbeSelection { + private owner: object | null = null; + private count = 0; + readonly indices = new Set(); + private regionStart: Set | undefined; + + bind(owner: object | null, count: number): void { + if (this.owner !== owner || this.count !== count) { + this.indices.clear(); + this.regionStart = undefined; + } + this.owner = owner; + this.count = count; + } + + all(): void { + this.indices.clear(); + for (let index = 0; index < this.count; index++) { this.indices.add(index); } + } + + beginRegion(): void { this.regionStart = new Set(this.indices); } + endRegion(): void { this.regionStart = undefined; } + + /** Every drag frame uses its initial snapshot, so shrinking an additive box removes transient hits. */ + region(hits: Iterable, additive: boolean): void { + const baseline = additive ? this.regionStart ?? new Set(this.indices) : []; + this.indices.clear(); + for (const index of [...baseline, ...hits]) { + if (index >= 0 && index < this.count) { this.indices.add(index); } + } + } +} diff --git a/src/core/scene/scene-process/service/gizmo/gizmo-operation.ts b/src/core/scene/scene-process/service/gizmo/gizmo-operation.ts index a3694d091..e705c6a56 100644 --- a/src/core/scene/scene-process/service/gizmo/gizmo-operation.ts +++ b/src/core/scene/scene-process/service/gizmo/gizmo-operation.ts @@ -8,6 +8,7 @@ import { getRaycastResults, raycast, RaycastResults } from './utils/engine-utils import { getRaycastResultNodes, getRegionNodes } from './utils/node-utils'; import { getSelectNode } from './utils/selection-utils'; import { getEditorNodeByPath, getEditorNodePath } from './utils/editor-node'; +import { probeSelectionEvents } from './components/light-probe-group/selection'; function getService(): any { try { @@ -81,6 +82,26 @@ class GizmoOperation { private _noGizmoMouseDownEvent: GizmoMouseEvent | null = null; private _mouseDownRaycastGizmos: RaycastResults | null = null; private _anyKeyDown = false; + private _probeRegionDown: GizmoMouseEvent | undefined; + private _probeRegionDragged = false; + + private beginProbeRegion(event: GizmoMouseEvent): boolean { + if (!event.leftButton || event.altKey || (event.propagationStopped && !probeSelectionEvents.has(event)) || !getServiceProp('Gizmo')?.queryLightProbeEditMode?.() + || getServiceProp('Camera')?.controller?.isMoving?.()) { return false; } + this._probeRegionDown = event; + this._probeRegionDragged = false; + return true; + } + + private endProbeRegion(): void { + this._probeRegionDown = undefined; + this._probeRegionDragged = false; + this._curMouseDownInfos.length = 0; + this._gizmoMouseDownEvent = null; + this._noGizmoMouseDownEvent = null; + getServiceProp('Gizmo')?.execGizmoMethods('cc.LightProbeGroup', 'endRegion'); + this._hideSelectionRegion(); + } /** * Raycast against gizmo nodes @@ -225,6 +246,10 @@ class GizmoOperation { this._anyKeyDown = event.altKey || event.ctrlKey || event.shiftKey || event.metaKey; const customEvent = createGizmoMouseEvent('mouseDown', event); + // Snapshot before point-click selection changes, not after; modifiers belong to mouse-down. + if (customEvent.leftButton && !customEvent.altKey && getServiceProp('Gizmo')?.queryLightProbeEditMode?.()) { + getServiceProp('Gizmo')?.execGizmoMethods('cc.LightProbeGroup', 'beginRegion'); + } // 与 cocos-editor 一致:不区分按键,始终做 raycast const results = this.raycastGizmos(customEvent.x, customEvent.y); @@ -232,9 +257,12 @@ class GizmoOperation { if (results.length > 0) { this._gizmoMouseDownEvent = customEvent; - return this._onGizmoMouseDown(customEvent, results); + const result = this._onGizmoMouseDown(customEvent, results); + if (!this.beginProbeRegion(customEvent)) { getServiceProp('Gizmo')?.execGizmoMethods('cc.LightProbeGroup', 'endRegion'); } + return result; } + if (this.beginProbeRegion(customEvent)) { return false; } this._noGizmoMouseDownEvent = customEvent; this._onNotGizmoMouseDown(customEvent); } @@ -243,6 +271,14 @@ class GizmoOperation { this._anyKeyDown = false; const customEvent = createGizmoMouseEvent('mouseUp', event); + if (this._probeRegionDown) { + if (!this._probeRegionDragged && !probeSelectionEvents.has(this._probeRegionDown) && !this._probeRegionDown.ctrlKey && !this._probeRegionDown.metaKey && !this._probeRegionDown.shiftKey) { + getServiceProp('Gizmo')?.unselectAllLightProbes?.(); + } + this.endProbeRegion(); + return false; + } + if (this._mouseDownRaycastGizmos && this._mouseDownRaycastGizmos.length > 0) { if (!this._gizmoMouseDownEvent) return true; this._gizmoMouseDownEvent = null; @@ -257,6 +293,19 @@ class GizmoOperation { public onMouseMove(event: ISceneMouseEvent): boolean | void { this._gizmoMoved = true; const customEvent = createGizmoMouseEvent('mouseMove', event); + const probeDown = this._probeRegionDown; + if (probeDown) { + if (!getServiceProp('Gizmo')?.queryLightProbeEditMode?.()) { this.endProbeRegion(); return false; } + if (Math.hypot(customEvent.x - probeDown.x, customEvent.y - probeDown.y) < 10 && !this._probeRegionDragged) { return false; } + this._probeRegionDragged = true; + const left = Math.min(probeDown.x, customEvent.x); + const right = Math.max(probeDown.x, customEvent.x); + const bottom = Math.min(probeDown.y, customEvent.y); + const top = Math.max(probeDown.y, customEvent.y); + this._showSelectionRegion(left, right, top, bottom); + getServiceProp('Gizmo')?.regionSelectLightProbes?.(left, right, top, bottom, probeDown.ctrlKey || probeDown.metaKey || probeDown.shiftKey); + return false; + } const results = this.raycastGizmos(customEvent.x, customEvent.y); if (this._mouseDownRaycastGizmos && this._mouseDownRaycastGizmos.length > 0) { diff --git a/src/core/scene/scene-process/service/service-manager.ts b/src/core/scene/scene-process/service/service-manager.ts index d938aaf94..475274819 100644 --- a/src/core/scene/scene-process/service/service-manager.ts +++ b/src/core/scene/scene-process/service/service-manager.ts @@ -14,6 +14,8 @@ type EventMap = { // 仅需 messageManager 转发、无服务方法扇出的事件 const MESSAGE_ONLY_EVENTS = [ + 'scene:light-probe-edit-mode-changed', + 'scene:light-probe-bounding-box-edit-mode-changed', 'dirty:changed', 'animation:state-changed', 'animation:time-changed', diff --git a/src/core/scene/test/light-probe-edit-gizmo.test.ts b/src/core/scene/test/light-probe-edit-gizmo.test.ts new file mode 100644 index 000000000..305683a64 --- /dev/null +++ b/src/core/scene/test/light-probe-edit-gizmo.test.ts @@ -0,0 +1,108 @@ +const mockService = { + Gizmo: { transformToolName: 'position', transformToolData: { viewMode: 'select' } }, + Engine: { repaintInEditMode: jest.fn() }, + Undo: { beginRecording: jest.fn(() => 'record'), endRecording: jest.fn(async (_id: string) => {}) }, +}; +jest.mock('../scene-process/service/core/decorator', () => ({ Service: mockService })); +jest.mock('../scene-process/service/core/global-events', () => ({ ServiceEvents: { broadcast: jest.fn() } })); +jest.mock('cc', () => { + class Vec3 { + constructor(public x = 0, public y = 0, public z = 0) {} + static clone(point: Vec3) { return new Vec3(point.x, point.y, point.z); } + } + return { Vec3, Quat: class {}, Color: class {}, LightProbeGroup: class {}, js: { getClassName: () => 'cc.LightProbeGroup' } }; +}); +jest.mock('../scene-process/service/gizmo/base/gizmo-base', () => ({ + __esModule: true, + default: class { + private value: unknown; + constructor(value: unknown) { this.value = value; } + get target() { return this.value; } + set target(value: unknown) { this.value = value; } + }, +})); +jest.mock('../scene-process/service/gizmo/base/gizmo-icon', () => ({ __esModule: true, default: class {} })); +jest.mock('../scene-process/service/gizmo/controller/box', () => ({ __esModule: true, default: class {} })); +jest.mock('../scene-process/service/gizmo/node/position-controller', () => ({ __esModule: true, default: class {} })); +jest.mock('../scene-process/service/gizmo/utils/controller-utils', () => ({ __esModule: true, default: {} })); +jest.mock('../scene-process/service/gizmo/utils/engine-utils', () => ({})); +jest.mock('../scene-process/service/gizmo/gizmo-defines', () => ({ registerGizmo: jest.fn() })); + +import type { LightProbeGroup } from 'cc'; +import { SelectGizmo, methods } from '../scene-process/service/gizmo/components/light-probe-group'; + +function target(uuid: string, count: number): LightProbeGroup { + return { isValid: true, enabledInHierarchy: true, node: { uuid }, + probes: Array.from({ length: count }, (_, x) => ({ x, y: 0, z: 0 })) } as unknown as LightProbeGroup; +} + +const created: InstanceType[] = []; +function group(uuid: string, count: number) { + const gizmo = new SelectGizmo(target(uuid, count)); + jest.spyOn(gizmo, 'createController').mockImplementation(() => { + Object.assign(gizmo, { _controller: { hide: jest.fn(), shape: { destroy: jest.fn() } } }); + }); + jest.spyOn(gizmo, 'updateControllerData').mockImplementation(() => { + gizmo.selection.bind(gizmo.target, gizmo.target?.probes.length ?? 0); + }); + jest.spyOn(gizmo, 'refreshSelection').mockImplementation(() => {}); + jest.spyOn(gizmo, 'probesChanged').mockImplementation(() => gizmo.updateControllerData()); + gizmo.init(); + gizmo.onShow(); + created.push(gizmo); + return gizmo; +} + +afterEach(() => { + for (const gizmo of created.splice(0)) { gizmo.onHide(); gizmo.onDestroy(); } + jest.clearAllMocks(); +}); + +describe('Probe editing pooled Gizmos', () => { + it('counts only visible valid groups and clears a reused target with the same probe count', () => { + const first = group('a', 32); + const second = group('b', 32); + methods.changeEditMode('vertex'); + methods.selectAllProbes(); + expect(methods.getSelectedProbeCount()).toBe(64); + first.onHide(); + methods.selectAllProbes(); + expect([methods.getSelectedProbeCount(), first.selection.indices.size]).toEqual([32, 0]); + first.target = target('c', 32); + first.onShow(); + expect(methods.getSelectedProbeCount()).toBe(32); + second.onHide(); + methods.selectAllProbes(); + expect([methods.getSelectedProbeCount(), second.selection.indices.size]).toEqual([32, 0]); + }); + + it('does not count disabled targets and returns to normal tools after the last group hides', () => { + const gizmo = group('a', 4); + methods.changeEditMode('vertex'); + methods.selectAllProbes(); + Object.assign(gizmo.target!, { enabledInHierarchy: false }); + expect(methods.getSelectedProbeCount()).toBe(0); + gizmo.onHide(); + expect([methods.getEditMode(), mockService.Gizmo.transformToolName]).toEqual(['none', 'position']); + }); + + it('duplicates selected probes but never a hidden group and waits for the recording', async () => { + const hidden = group('hidden', 4); + const active = group('active', 4); + methods.changeEditMode('vertex'); + methods.selectAllProbes(); + hidden.onHide(); + let settle!: () => void; + mockService.Undo.endRecording.mockImplementationOnce(() => new Promise(resolve => { settle = resolve; })); + let finished = false; + const operation = methods.duplicateSelectedProbes().then(count => { finished = true; return count; }); + await Promise.resolve(); + expect([hidden.target!.probes.length, active.target!.probes.length, finished]).toEqual([4, 8, false]); + expect(mockService.Undo.beginRecording).toHaveBeenCalledWith(['active']); + settle(); + expect(await operation).toBe(4); + expect([...active.selection.indices]).toEqual([4, 5, 6, 7]); + expect(await methods.deleteSelectedProbes()).toBe(4); + expect([active.target!.probes.length, methods.getSelectedProbeCount()]).toEqual([4, 0]); + }); +}); diff --git a/src/core/scene/test/light-probe-selection.test.ts b/src/core/scene/test/light-probe-selection.test.ts new file mode 100644 index 000000000..6fcf28454 --- /dev/null +++ b/src/core/scene/test/light-probe-selection.test.ts @@ -0,0 +1,56 @@ +import { ProbeSelection } from '../scene-process/service/gizmo/components/light-probe-group/selection'; + +describe('ProbeSelection', () => { + it('does not carry selection across pooled targets, including equally sized groups', () => { + const selection = new ProbeSelection(); + const first = {}; + selection.bind(first, 32); + selection.all(); + expect(selection.indices.size).toBe(32); + selection.bind({}, 32); + expect([...selection.indices]).toEqual([]); + selection.all(); + selection.bind(null, 0); + expect([...selection.indices]).toEqual([]); + selection.bind(first, 32); + expect([...selection.indices]).toEqual([]); + }); + + it('retains selection during a same-target move but drops invalid indices after regeneration', () => { + const selection = new ProbeSelection(); + const owner = {}; + selection.bind(owner, 4); + selection.all(); + selection.bind(owner, 4); + expect([...selection.indices]).toEqual([0, 1, 2, 3]); + selection.bind(owner, 2); + expect([...selection.indices]).toEqual([]); + }); + + it('uses the original additive selection on every frame and drops transient rectangle hits', () => { + const selection = new ProbeSelection(); + selection.bind({}, 5); + selection.indices.add(0); + selection.beginRegion(); + selection.region([1, 2, 3], true); + expect([...selection.indices]).toEqual([0, 1, 2, 3]); + selection.region([2], true); + expect([...selection.indices]).toEqual([0, 2]); + selection.region([], true); + expect([...selection.indices]).toEqual([0]); + selection.endRegion(); + selection.region([4], true); + expect([...selection.indices]).toEqual([0, 4]); + }); + + it('replacement selection ignores both the baseline and invalid indices', () => { + const selection = new ProbeSelection(); + selection.bind({}, 4); + selection.all(); + selection.beginRegion(); + selection.region([-1, 2, 9], false); + expect([...selection.indices]).toEqual([2]); + selection.region([], false); + expect([...selection.indices]).toEqual([]); + }); +}); From b690ed4365acb76447482ed5648e5c0d6bdfab3b Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 17:23:33 +0800 Subject: [PATCH 24/64] =?UTF-8?q?fix(scene):=20=E4=BF=9D=E7=95=99=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E6=94=B9=E7=88=B6=E8=8A=82=E7=82=B9=E7=9A=84=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E7=BB=93=E6=9E=9C=E6=92=A4=E9=94=80=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scene/scene-process/service/node/index.ts | 1 + .../scene-process/service/node/node-undo.ts | 21 +++-- .../scene/test/light-probe-reparent.test.ts | 81 +++++++++++++++++++ 3 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 src/core/scene/test/light-probe-reparent.test.ts diff --git a/src/core/scene/scene-process/service/node/index.ts b/src/core/scene/scene-process/service/node/index.ts index 9315f98c6..8222eeac3 100644 --- a/src/core/scene/scene-process/service/node/index.ts +++ b/src/core/scene/scene-process/service/node/index.ts @@ -271,6 +271,7 @@ export class NodeManager { const childAdded = child.parent === parent; if (childAdded) { NodeMgr.updateNodeParent(child.uuid, parent.uuid); + synchronizeLightProbeTransform(child); } this.emit('node:change', parent, { type: NodeEventType.CHILD_CHANGED }); diff --git a/src/core/scene/scene-process/service/node/node-undo.ts b/src/core/scene/scene-process/service/node/node-undo.ts index f3fd2c5a5..f4b0f56f2 100644 --- a/src/core/scene/scene-process/service/node/node-undo.ts +++ b/src/core/scene/scene-process/service/node/node-undo.ts @@ -31,6 +31,8 @@ export interface IComponentOrderSnapshot { } export interface INodeReparentSnapshot extends INodeSnapshot { + /** Affected probe globals restore last; the scene itself must never be reparented. */ + probeScene?: true; parentUuid: string | null; parentPath: string; siblingIndex: number; @@ -155,7 +157,7 @@ export class NodeUndoHelper { captureReparentSnapshots(nodes: Node[]): Map { const snapshots = new Map(); - for (const node of nodes) { + for (const node of withLightProbeTransformScenes(nodes)) { if (!node?.isValid) { continue; } @@ -167,6 +169,7 @@ export class NodeUndoHelper { parentUuid: parent?.uuid ?? null, parentPath: parent ? (NodeMgr.getNodePath(parent) ?? '/') : '/', siblingIndex: node.getSiblingIndex(), + ...(node === node.scene ? { probeScene: true as const } : {}), }); } return snapshots; @@ -181,9 +184,7 @@ export class NodeUndoHelper { if (!before || changedUuids.length === 0) { return; } - const afterNodes = changedUuids - .map(uuid => NodeMgr.getNode(uuid) as Node | null) - .filter((node): node is Node => !!node?.isValid); + const afterNodes = this.findSnapshotNodes(before); const after = this.captureReparentSnapshots(afterNodes); if (this.snapshotMapsEqual(before, after)) { return; @@ -535,7 +536,7 @@ export class NodeUndoHelper { } private async _applyReparentSnapshots(data: Map): Promise { - const snapshots = [...data.values()].sort((a, b) => a.siblingIndex - b.siblingIndex); + const snapshots = [...data.values()].sort((a, b) => Number(!!a.probeScene) - Number(!!b.probeScene) || a.siblingIndex - b.siblingIndex); for (const snapshot of snapshots) { const result = await this._applyReparentSnapshot(snapshot); if (!result.success) { @@ -550,6 +551,16 @@ export class NodeUndoHelper { if (!node) { return { success: false, reason: `Node not found: ${snapshot.path || snapshot.uuid}` }; } + if (snapshot.probeScene) { + if (node !== node.scene) { return { success: false, reason: 'Probe scene snapshot target is not the current scene.' }; } + try { + await this._restoreNodeSnapshotDump(node, snapshot.dump); + this._emit('node:change', node, { source: 'undo', type: NodeEventType.COMPONENT_CHANGED }); + return { success: true }; + } catch (error) { + return { success: false, reason: error instanceof Error ? error.message : String(error) }; + } + } const parent = this._findReparentParent(snapshot); if (!parent) { return { success: false, reason: `Parent node not found: ${snapshot.parentPath || snapshot.parentUuid || '/'}` }; diff --git a/src/core/scene/test/light-probe-reparent.test.ts b/src/core/scene/test/light-probe-reparent.test.ts new file mode 100644 index 000000000..1eafcd311 --- /dev/null +++ b/src/core/scene/test/light-probe-reparent.test.ts @@ -0,0 +1,81 @@ +export {}; + +const mockNodes = new Map(); +const mockEvents: string[] = []; +let mockCommand: any; +jest.mock('cc', () => ({ Node: class {}, Component: class {} })); +jest.mock('../scene-process/service/core', () => ({ Service: { Undo: { push: (command: any) => { mockCommand = command; } } } })); +jest.mock('../scene-process/service/node/index', () => ({ __esModule: true, default: {} })); +jest.mock('../scene-process/service/dump', () => ({ + __esModule: true, + default: { dumpNode: (node: any) => ({ marker: node.marker }) }, +})); +jest.mock('../scene-process/service/undo/commands/create-node-command', () => ({ CreateNodeCommand: {} })); +jest.mock('../scene-process/service/undo/commands/snapshot-command', () => ({ + SnapshotCommand: class { + constructor(public options: any, public before: any, public after: any, public adapter: any) {} + }, +})); +jest.mock('../scene-process/service/undo/commands/command-utils-shared', () => ({ + createUndoId: () => 'reparent', + snapshotMapsEqual: (before: Map, after: Map) => JSON.stringify([...before]) === JSON.stringify([...after]), + restoreNodeSnapshotDump: async (node: any, dump: any) => { mockEvents.push(`restore:${node.uuid}`); node.marker = dump.marker; }, +})); + +const previousExtends: unknown = Reflect.get(globalThis, 'EditorExtends'); +Object.assign(globalThis, { EditorExtends: { Node: { + getNode: (uuid: string) => mockNodes.get(uuid), + getNodePath: (node: { uuid: string }) => `/${node.uuid}`, + getNodeByPath: (path: string) => mockNodes.get(path.slice(1)), +} } }); +const { NodeUndoHelper } = require('../scene-process/service/node/node-undo'); + +function node(uuid: string, scene?: any): any { + const result: any = { uuid, isValid: true, scene, marker: `${uuid}:before`, parent: null, + getSiblingIndex: () => 0, setSiblingIndex: jest.fn(), + getComponentsInChildren: () => uuid === 'unrelated' ? [] : [{ isValid: true, enabledInHierarchy: true }], + setParent: jest.fn((parent: any) => { mockEvents.push(`parent:${uuid}`); result.parent = parent; }), + }; + mockNodes.set(uuid, result); + return result; +} + +afterAll(() => { Object.assign(globalThis, { EditorExtends: previousExtends }); }); +beforeEach(() => { mockNodes.clear(); mockEvents.length = 0; mockCommand = undefined; }); + +describe('Probe globals in reparent history', () => { + it('captures one affected scene and restores it after parent and node data without reparenting the root', async () => { + const scene = node('scene'); + scene.scene = scene; + scene.globals = { lightProbeInfo: {} }; + const oldParent = node('old', scene); + const newParent = node('new', scene); + const group = node('group', scene); + group.parent = oldParent; + const helper = new NodeUndoHelper(() => {}); + const before = helper.captureReparentSnapshots([group]); + expect([...before.keys()]).toEqual(['group', 'scene']); + group.parent = newParent; + group.marker = 'group:after'; + scene.marker = 'scene:after'; + helper.recordReparentSnapshots('reparent', 'Set Parent', before, ['group']); + expect([...mockCommand.after.keys()]).toEqual(['group', 'scene']); + expect(await mockCommand.adapter.apply(before)).toEqual({ success: true }); + expect(mockEvents).toEqual(['parent:group', 'restore:group', 'restore:scene']); + expect([group.parent.uuid, group.marker, scene.marker, scene.setParent.mock.calls.length]) + .toEqual(['old', 'group:before', 'scene:before', 0]); + mockEvents.length = 0; + expect(await mockCommand.adapter.apply(mockCommand.after)).toEqual({ success: true }); + expect(mockEvents).toEqual(['parent:group', 'restore:group', 'restore:scene']); + expect([group.parent.uuid, scene.marker]).toEqual(['new', 'scene:after']); + }); + + it('does not capture scene globals for a subtree with no enabled probes', () => { + const scene = node('scene'); + scene.scene = scene; + scene.globals = { lightProbeInfo: {} }; + const unrelated = node('unrelated', scene); + const helper = new NodeUndoHelper(() => {}); + expect([...helper.captureReparentSnapshots([unrelated]).keys()]).toEqual(['unrelated']); + }); +}); From 1bbadafde49d877b7e6768e6669f11fc99ac0bc5 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 17:33:23 +0800 Subject: [PATCH 25/64] =?UTF-8?q?fix(scene):=20=E6=92=A4=E9=94=80=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E7=BC=96=E8=BE=91=E6=97=B6=E5=90=8C=E6=AD=A5=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E6=B3=A8=E5=86=8C=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/dump/light-probe-group-restore.ts | 11 +++++++++++ .../service/undo/commands/command-utils-shared.ts | 2 ++ src/core/scene/test/undo-node-restore.test.ts | 11 +++++++++++ 3 files changed, 24 insertions(+) create mode 100644 src/core/scene/scene-process/service/dump/light-probe-group-restore.ts diff --git a/src/core/scene/scene-process/service/dump/light-probe-group-restore.ts b/src/core/scene/scene-process/service/dump/light-probe-group-restore.ts new file mode 100644 index 000000000..a972d978b --- /dev/null +++ b/src/core/scene/scene-process/service/dump/light-probe-group-restore.ts @@ -0,0 +1,11 @@ +import type { LightProbeGroup } from 'cc'; + +/** Replace the engine's registered array reference without rebuilding restored SH. */ +export function restoreLightProbeGroupCache(component: object, dump: { type?: string; extends?: string[]; value?: object }): void { + if ((dump.type !== 'cc.LightProbeGroup' && !dump.extends?.includes('cc.LightProbeGroup')) || !dump.value || !('probes' in dump.value || '_probes' in dump.value)) return; + const group = component as LightProbeGroup; + if (!group.isValid || !group.enabledInHierarchy) return; + // onRestore is absent on LightProbeGroup. Calling onProbeChanged here would + // rebuild the global table before every group's snapshot has been restored. + group.node.scene?.globals.lightProbeInfo.syncData(group.node, group.probes); +} diff --git a/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts b/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts index a642b74ed..bd0b48714 100644 --- a/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts +++ b/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts @@ -1,6 +1,7 @@ import { Component, Node } from 'cc'; import type { IUndoCommandMeta, IUndoRedoResult } from '../../../../common'; import { restoreTerrainLightmapBindings } from '../../dump/terrain-lightmap-restore'; +import { restoreLightProbeGroupCache } from '../../dump/light-probe-group-restore'; export function createUndoId(prefix: string): string { try { @@ -140,4 +141,5 @@ export async function restoreComponentSnapshotDump( await dumpUtil.restoreComponentSnapshotProperties(component, dump); (component as any).onRestore?.(); restoreTerrainLightmapBindings(component, dump); + restoreLightProbeGroupCache(component, dump); } diff --git a/src/core/scene/test/undo-node-restore.test.ts b/src/core/scene/test/undo-node-restore.test.ts index d4dae510b..1401bcd96 100644 --- a/src/core/scene/test/undo-node-restore.test.ts +++ b/src/core/scene/test/undo-node-restore.test.ts @@ -69,6 +69,17 @@ describe('restoreComponentSnapshotDump', () => { mockRestoreComponentSnapshotProperties.mockReset(); }); + it.each(['cc.LightProbeGroup', 'CustomProbeGroup'])('rebinds restored %s probe arrays without rebuilding global data', async type => { + const old = [1, 2, 3, 4, 5]; + const restored = [1, 2, 3, 4]; + const info = { syncData: jest.fn(), update: jest.fn() }; + const component = { isValid: true, enabledInHierarchy: true, probes: old, node: { scene: { globals: { lightProbeInfo: info } } } }; + mockRestoreComponentSnapshotProperties.mockImplementationOnce(async () => { component.probes = restored; }); + await restoreComponentSnapshotDump(component as any, { type, extends: ['cc.LightProbeGroup'], value: { _probes: {} } }); + expect(info.syncData).toHaveBeenCalledWith(component.node, restored); + expect(info.update).not.toHaveBeenCalled(); + }); + it('refreshes Terrain block bindings after properties and the engine lifecycle have restored', async () => { const events: string[] = []; const restoredInfo = { texture: 'restored-texture' }; From ec824ae6d8704ca21091fdb4993c9fee23283746 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 17:36:34 +0800 Subject: [PATCH 26/64] =?UTF-8?q?feat(lighting):=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E7=83=98=E7=84=99=E5=AF=B9=E8=B1=A1=E8=AF=8A=E6=96=AD=E4=B8=8E?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=BD=92=E5=B1=9E=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/scene/common/lightfx-bake.ts | 24 +++++++- src/core/scene/common/lightfx-host.ts | 10 ++++ .../scene/main-process/lightfx-bake-host.ts | 39 +++++++++++- .../service/baking/lightfx/baker.ts | 14 ++++- .../service/baking/lightfx/exporter.ts | 3 +- .../service/baking/lightfx/host.ts | 1 + .../service/baking/lightfx/readiness.ts | 59 +++++++++++++++++++ .../scene-process/service/light-probe-bake.ts | 2 + .../scene-process/service/lightmap-bake.ts | 4 ++ .../scene/test/lightfx-asset-versions.test.ts | 2 +- src/core/scene/test/lightfx-bake-host.test.ts | 31 +++++++++- .../scene/test/lightmap-bake-info.test.ts | 4 ++ .../scene/test/lightmap-readiness.test.ts | 42 +++++++++++++ 13 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 src/core/scene/scene-process/service/baking/lightfx/readiness.ts create mode 100644 src/core/scene/test/lightmap-readiness.test.ts diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts index e7ad8c287..53078b635 100644 --- a/src/core/scene/common/lightfx-bake.ts +++ b/src/core/scene/common/lightfx-bake.ts @@ -1,5 +1,5 @@ import type { IServiceEvents } from '../scene-process/service/core'; -import type { ILightmapTextureInfo } from './lightfx-host'; +import type { ILightmapTextureInfo, ILightFXDiagnostics } from './lightfx-host'; export interface ILightProbeBakeOptions { giScale?: number; @@ -15,6 +15,7 @@ export interface ILightProbeBakeOptions { /** Versioned implementation support, not native executable readiness or a recoverable task. */ export interface ILightProbeBakeCapabilities { + diagnostics?: ILightFXDiagnostics; version: 1; /** Same-Scene probe cancellation verifies the actual host's native operation ownership. */ cancelVersion?: 1; @@ -29,6 +30,7 @@ export interface ILightProbeBakeCapabilities { } export interface ILightProbeBakeResult { + diagnostics?: ILightFXDiagnostics; sceneUrl: string; probeCount: number; giScale: number; @@ -60,6 +62,7 @@ export interface ILightmapBakeOptions { /** Implementation support, not native executable readiness, task recovery or safe asset deletion. */ export interface ILightmapBakeCapabilities { + diagnostics?: ILightFXDiagnostics; version: 1; /** Mesh/Terrain bindings, null references and live blocks are restored with the result history. */ resultLifecycleVersion: 1; @@ -74,6 +77,7 @@ export interface ILightmapBakeCapabilities { } export interface ILightmapBakeResult { + diagnostics?: ILightFXDiagnostics; sceneUrl: string; textureUrls: string[]; meshCount: number; @@ -82,6 +86,8 @@ export interface ILightmapBakeResult { } export interface ILightmapBakeInfo { + /** Read-only next-bake diagnostics; absent on older runtimes. Does not guarantee image quality. */ + readiness?: ILightmapReadiness; sceneUrl: string; baked: boolean; meshCount: number; @@ -92,6 +98,22 @@ export interface ILightmapBakeInfo { missingTextureUuids: string[]; } +export type LightmapObjectIssue = 'inactive' | 'movable' | 'editor-only' | 'disabled' | 'not-participating' + | 'missing-mesh' | 'invalid-uv1' | 'skinned-static-pose' | 'material-approximation' | 'terrain-translation-only'; + +export interface ILightmapReadiness { + version: 1; + objects: { + componentUuid: string; + nodeName: string; + kind: 'mesh' | 'terrain'; + receivesLightmap: boolean; + castsShadow: boolean; + lightmapSize: number; + issues: LightmapObjectIssue[]; + }[]; +} + export interface ILightFXCancelResult { cancelled: boolean; target: 'light-probe' | 'lightmap' | null; diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts index 6f11a3357..02cdf12b8 100644 --- a/src/core/scene/common/lightfx-host.ts +++ b/src/core/scene/common/lightfx-host.ts @@ -18,9 +18,18 @@ export interface ILightFXHostCapabilities { lightmapAssetVersion?: 1; /** Version 1 requires the exact native operation, target and scene reservation to cancel. */ cancelOwnershipVersion?: 1; + diagnosticsVersion?: 1; busy: boolean; } +/** Native diagnostic text is informational, never a progress percentage or an instruction. */ +export interface ILightFXDiagnostics { + version: 1; + stage: string; + logs: string[]; + progress?: string; +} + /** JSON-safe reference to a texture needed by a LightFX input file. */ export interface ILightFXTextureSource { uuid: string; @@ -127,6 +136,7 @@ export interface IQueryLightmapTextureInfoResult { * return value in this contract must remain JSON serializable and must not expose host file paths. */ export interface ILightFXBakeHostService { + queryDiagnostics?(options: ICancelLightFXOperationOptions): Promise; queryCapabilities(): Promise; reserveSceneOperation(options: IReserveLightFXSceneOperationOptions): Promise; releaseSceneOperation(options: ILightFXSceneOperationToken): Promise; diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index 85c443a20..1139f8eaa 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -31,6 +31,7 @@ import type { IReserveLightFXSceneOperationOptions, ILightFXSceneOperationToken, ICancelLightFXOperationOptions, + ILightFXDiagnostics, } from '../common/lightfx-host'; import { assetManager } from '../../assets'; import { LightmapAssetTransaction } from './lightfx/asset-transaction'; @@ -78,11 +79,29 @@ const MAX_TEXTURE_SOURCES = 10_000; export class LightFXBakeHost implements ILightFXBakeHostService { private operation: LightFXHostOperation | null = null; private readonly completedOperations = new Map(); + private readonly diagnostics = new Map(); private sceneOperation: (IReserveLightFXSceneOperationOptions & ILightFXSceneOperationToken & { nativeStarted: boolean; removingAssets: boolean }) | null = null; private readonly releasedSceneOperations = new Set(); public async queryCapabilities(): Promise { - return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; + return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; + } + + public async queryDiagnostics(options: ICancelLightFXOperationOptions): Promise { + const entry = this.diagnostics.get(options?.operationId); + if (!entry || entry.owner.target !== options.target || entry.owner.transactionId !== options.transactionId) { return undefined; } + return structuredClone(entry.value); + } + + private diagnosticText(operation: LightFXHostOperation, value: unknown): string { + let text: string; + try { text = typeof value === 'string' ? value : JSON.stringify(value) ?? ''; } catch { return ''; } + for (const [path, label] of [[operation.workspace, ''], [operation.targetDir, '']]) { + if (!path) continue; + // Object payloads have JSON-escaped Windows paths; plain logs do not. + text = text.split(JSON.stringify(path).slice(1, -1)).join(label).split(path).join(label); + } + return text.slice(0, 2048); } public async reserveSceneOperation(options: IReserveLightFXSceneOperationOptions): Promise { @@ -215,6 +234,8 @@ export class LightFXBakeHost implements ILightFXBakeHostService { // Reserve the global operation before the first asynchronous filesystem call. this.operation = operation; + this.diagnostics.set(operationId, { owner: { operationId, target: options.target, transactionId: options.transactionId }, value: { version: 1, stage: 'accepting-input', logs: [] } }); + if (this.diagnostics.size > MAX_REMEMBERED_OPERATIONS) { this.diagnostics.delete(this.diagnostics.keys().next().value!); } if (this.sceneOperation) this.sceneOperation.nativeStarted = true; try { await ensureDir(tmpDir); @@ -259,6 +280,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { throw new Error('LightFX bake has already started.'); } operation.state = 'running'; + this.diagnostics.get(operation.id)!.value.stage = 'running'; try { await operation.inputWritePromise; @@ -270,7 +292,17 @@ export class LightFXBakeHost implements ILightFXBakeHostService { cwd: operation.workspace, timeoutMs: operation.timeoutMs, signal: operation.controller.signal, - onLog: (line) => console.log(`[LightFX] ${line}`), + onLog: message => { + if (this.operation !== operation || operation.terminalState) { return; } + console.log(`[LightFX] ${message}`); + const logs = this.diagnostics.get(operation.id)!.value.logs; + logs.push(this.diagnosticText(operation, message)); + if (logs.length > 128) { logs.shift(); } + }, + onProgress: progress => { + if (this.operation !== operation || operation.terminalState) { return; } + this.diagnostics.get(operation.id)!.value.progress = this.diagnosticText(operation, progress); + }, }); this.throwIfTerminated(operation); const result = decodeLightFXOutput(await readFile(join(operation.outputDir, 'lfx.out'))); @@ -279,6 +311,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { : []; this.throwIfTerminated(operation); operation.state = 'awaiting-commit'; + this.diagnostics.get(operation.id)!.value.stage = 'awaiting-commit'; return { result, textureUrls }; } catch (error) { const terminalError = operation.terminalState === 'cancelled' || operation.terminalState === 'expired' @@ -616,6 +649,8 @@ export class LightFXBakeHost implements ILightFXBakeHostService { } private decideTerminalState(operation: LightFXHostOperation, state: OperationTerminalState): void { + const diagnostic = this.diagnostics.get(operation.id); + if (diagnostic && !operation.terminalState) { diagnostic.value.stage = state; } if (operation.terminalState) { if (operation.terminalState === state) { return; diff --git a/src/core/scene/scene-process/service/baking/lightfx/baker.ts b/src/core/scene/scene-process/service/baking/lightfx/baker.ts index a8902dac1..10c62149e 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/baker.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/baker.ts @@ -5,7 +5,7 @@ import { LightFXExporter, LightFXExport } from './exporter'; import { lightFXBakeHost } from './host'; import { lightFXSceneOperation } from './scene-operation'; import { LightFXBakeTarget, LightFXResult, LightFXSettings } from './types'; -import type { ICancelLightFXOperationOptions } from '../../../../common/lightfx-host'; +import type { ICancelLightFXOperationOptions, ILightFXDiagnostics } from '../../../../common/lightfx-host'; const INPUT_CHUNK_SIZE = 512 * 1024; @@ -18,6 +18,16 @@ export interface LightFXBakeOutput extends LightFXExport { export class LightFXCoordinator { private target: LightFXBakeTarget | null = null; private operation: ICancelLightFXOperationOptions | null = null; + private lastOperation: ICancelLightFXOperationOptions | null = null; + + async queryDiagnostics(target: LightFXBakeTarget): Promise { + const owner = this.operation ?? this.lastOperation; + if (owner?.target !== target) { return undefined; } + try { + const value = await lightFXBakeHost.queryDiagnostics?.(owner); + return owner === (this.operation ?? this.lastOperation) ? value : undefined; + } catch { return undefined; } + } get activeTarget(): LightFXBakeTarget | null { return this.target; } @@ -26,6 +36,7 @@ export class LightFXCoordinator { async bake(scene: Scene, target: LightFXBakeTarget, settings: LightFXSettings, timeoutMs: number): Promise { if (this.target) throw new Error(`A ${this.target} LightFX bake is already in progress.`); this.target = target; + this.lastOperation = null; let operationId: string | undefined; try { const exported = await new LightFXExporter().export(scene, target, settings); @@ -38,6 +49,7 @@ export class LightFXCoordinator { timeoutMs, })); this.operation = { operationId, transactionId, target }; + this.lastOperation = this.operation; const input = encodeLightFXInput(exported.world); for (let offset = 0; offset < input.length; offset += INPUT_CHUNK_SIZE) { await lightFXBakeHost.appendInput({ diff --git a/src/core/scene/scene-process/service/baking/lightfx/exporter.ts b/src/core/scene/scene-process/service/baking/lightfx/exporter.ts index 9b8b49bf1..c215ba786 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/exporter.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/exporter.ts @@ -2,6 +2,7 @@ import { DirectionalLight, director, gfx, Light, MeshRenderer, MobilityMode, ren import type { ILightFXTextureSource } from '../../../../common/lightfx-host'; import { lightFXBakeHost } from './host'; import { LightFXBakeTarget, LightFXLight, LightFXMaterial, LightFXMesh, LightFXSettings, LightFXTerrain, LightFXWorld } from './types'; +import { validLightmapUV } from './readiness'; export interface LightFXExport { world: LightFXWorld; @@ -54,7 +55,7 @@ export class LightFXExporter { const positions: any = mesh.readAttribute(primitive, gfx.AttributeName.ATTR_POSITION); const normals: any = mesh.readAttribute(primitive, gfx.AttributeName.ATTR_NORMAL); const indices: any = mesh.readIndices(primitive); const uvs: any = mesh.readAttribute(primitive, gfx.AttributeName.ATTR_TEX_COORD); const luvs: any = mesh.readAttribute(primitive, gfx.AttributeName.ATTR_TEX_COORD1); if (!positions || !normals || !indices || positions.length !== normals.length) throw new Error(`Mesh has invalid position, normal or index data: ${model.node.name}`); - if (target === 'lightmap' && out.lightmapSize > 0 && !luvs) throw new Error(`Mesh is missing lightmap UV: ${model.node.name}`); + if (target === 'lightmap' && out.lightmapSize > 0 && !validLightmapUV(luvs, positions.length / 3)) throw new Error(`Mesh has missing or invalid lightmap UV: ${model.node.name}`); for (let i = 0; i < positions.length / 3; i++) { const p = new Vec3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]); const n = new Vec3(normals[i * 3], normals[i * 3 + 1], normals[i * 3 + 2]); Vec3.transformMat4(p, p, matrix); Vec3.transformMat4Normal(n, n, matrix).normalize(); diff --git a/src/core/scene/scene-process/service/baking/lightfx/host.ts b/src/core/scene/scene-process/service/baking/lightfx/host.ts index 3ffd79abf..c9ef9330d 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/host.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/host.ts @@ -17,6 +17,7 @@ import { Rpc } from '../../../rpc'; /** JSON-only bridge from either a child scene process or a browser scene Webview to the Node host. */ export const lightFXBakeHost: ILightFXBakeHostService = { + queryDiagnostics: options => Rpc.getInstance().request('lightFXBakeHost', 'queryDiagnostics', [options]), queryCapabilities: () => Rpc.getInstance().request('lightFXBakeHost', 'queryCapabilities'), reserveSceneOperation: (options) => Rpc.getInstance().request('lightFXBakeHost', 'reserveSceneOperation', [options]), releaseSceneOperation: (options) => Rpc.getInstance().request('lightFXBakeHost', 'releaseSceneOperation', [options]), diff --git a/src/core/scene/scene-process/service/baking/lightfx/readiness.ts b/src/core/scene/scene-process/service/baking/lightfx/readiness.ts new file mode 100644 index 000000000..bea8a25df --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/readiness.ts @@ -0,0 +1,59 @@ +import { gfx, MeshRenderer, MobilityMode, Scene, SkinnedMeshRenderer, Terrain, type Node } from 'cc'; +import type { ILightmapReadiness, LightmapObjectIssue } from '../../../../common/lightfx-bake'; + +/** UV presence alone is insufficient: truncated or non-finite attributes cannot be exported safely. */ +export function validLightmapUV(uv: ArrayLike | null, vertexCount: number): boolean { + if (!uv || !Number.isInteger(vertexCount) || vertexCount <= 0 || uv.length !== vertexCount * 2) { return false; } + for (let index = 0; index < uv.length; index++) { + if (!Number.isFinite(uv[index])) { return false; } + } + return true; +} + +/** Mirrors exporter participation without exporting geometry, resolving textures, or mutating a scene. */ +export function queryLightmapReadiness(scene: Scene): ILightmapReadiness { + const objects: ILightmapReadiness['objects'] = []; + const visit = (node: Node, inherited: LightmapObjectIssue[]) => { + // Gizmo/controllers live below the scene but are not user bake candidates. + if (node !== scene && (node._objFlags & (1 << 10))) { return; } + const excluded = [...inherited]; + if (node !== scene) { + if (!node.activeInHierarchy) { excluded.push('inactive'); } + if (node.mobility === MobilityMode.Movable) { excluded.push('movable'); } + for (const model of node.getComponents(MeshRenderer)) { + const issues = [...new Set(excluded)]; + if (!model.enabled) { issues.push('disabled'); } + const settings = model.bakeSettings; + if (!settings.bakeable && !settings.castShadow) { issues.push('not-participating'); } + if (!model.mesh) { issues.push('missing-mesh'); } + const participates = !issues.length; + const receivesLightmap = participates && settings.bakeable && settings.lightmapSize > 0; + if (receivesLightmap && model.mesh) { + for (let primitive = 0; primitive < model.mesh.struct.primitives.length; primitive++) { + const positions = model.mesh.readAttribute(primitive, gfx.AttributeName.ATTR_POSITION); + const uv = model.mesh.readAttribute(primitive, gfx.AttributeName.ATTR_TEX_COORD1); + if (!validLightmapUV(uv, (positions?.length ?? 0) / 3)) { issues.push('invalid-uv1'); break; } + } + } + if (participates) { + if (model instanceof SkinnedMeshRenderer) { issues.push('skinned-static-pose'); } + issues.push('material-approximation'); + } + objects.push({ componentUuid: model.uuid, nodeName: node.name, kind: 'mesh', receivesLightmap, + castsShadow: participates && settings.castShadow, lightmapSize: settings.lightmapSize, issues }); + } + for (const terrain of node.getComponents(Terrain)) { + const issues = [...new Set(excluded)]; + if (!terrain.enabled) { issues.push('disabled'); } + const participates = !issues.length; + if (participates) { issues.push('terrain-translation-only'); } + objects.push({ componentUuid: terrain.uuid, nodeName: node.name, kind: 'terrain', + receivesLightmap: participates && terrain.lightMapSize > 0, castsShadow: participates, + lightmapSize: terrain.lightMapSize, issues }); + } + } + for (const child of node.children) { visit(child, excluded); } + }; + visit(scene, []); + return { version: 1, objects }; +} diff --git a/src/core/scene/scene-process/service/light-probe-bake.ts b/src/core/scene/scene-process/service/light-probe-bake.ts index 40ac1fe53..d8f562f91 100644 --- a/src/core/scene/scene-process/service/light-probe-bake.ts +++ b/src/core/scene/scene-process/service/light-probe-bake.ts @@ -37,6 +37,7 @@ export class LightProbeBakeService extends BaseService imple throw new Error('The LightFX host does not support scene transaction protocol version 1.'); } return { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, + ...(host.diagnosticsVersion === 1 ? { diagnostics: await lightFXCoordinator.queryDiagnostics('light-probe') } : {}), ...(host.cancelOwnershipVersion === 1 ? { cancelVersion: 1 as const, cancellable: lightFXCoordinator.canCancel('light-probe') } : {}), busy: host.busy }; } @@ -96,6 +97,7 @@ export class LightProbeBakeService extends BaseService imple probeCount: probes.length, ...settingsToApply, durationMs: Date.now() - started, + diagnostics: await lightFXCoordinator.queryDiagnostics?.('light-probe'), }; } catch (error) { if (output) await lightFXCoordinator.rollback(output.operationId).catch(() => undefined); diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index 6ee42c957..88eba4477 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -12,6 +12,7 @@ import { lightFXSceneOperation } from './baking/lightfx/scene-operation'; import { finishSavedLightFXRecording } from './baking/lightfx/saved-recording'; import { BaseService, register, Service } from './core'; import { loadPreviewAsset } from './preview/asset-reload'; +import { queryLightmapReadiness } from './baking/lightfx/readiness'; interface LightmapBinding { target: any; @@ -28,6 +29,7 @@ export class LightmapBakeService extends BaseService impleme throw new Error('The LightFX host does not support scene transaction and immutable Lightmap asset protocol version 1.'); } return { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, + ...(host.diagnosticsVersion === 1 ? { diagnostics: await lightFXCoordinator.queryDiagnostics('lightmap') } : {}), ...(host.cancelOwnershipVersion === 1 ? { cancelVersion: 1 as const, cancellable: lightFXCoordinator.canCancel('lightmap') } : {}), busy: host.busy }; } @@ -98,6 +100,7 @@ export class LightmapBakeService extends BaseService impleme meshCount: output.result.meshes.length, terrainCount: output.result.terrains.length, durationMs: Date.now() - started, + diagnostics: await lightFXCoordinator.queryDiagnostics?.('lightmap'), }; } catch (error) { if (output) await lightFXCoordinator.rollback(output.operationId).catch((rollbackError) => { @@ -141,6 +144,7 @@ export class LightmapBakeService extends BaseService impleme }); return { sceneUrl: await this.querySceneUrl(), + readiness: queryLightmapReadiness(scene), baked: meshCount > 0 || terrainCount > 0, meshCount, terrainCount, diff --git a/src/core/scene/test/lightfx-asset-versions.test.ts b/src/core/scene/test/lightfx-asset-versions.test.ts index 61a37660a..8bdbae182 100644 --- a/src/core/scene/test/lightfx-asset-versions.test.ts +++ b/src/core/scene/test/lightfx-asset-versions.test.ts @@ -73,6 +73,6 @@ describe('Immutable Lightmap asset versions', () => { mockAssets.refreshAsset.mockRejectedValueOnce(new Error('import unavailable')); await expect(bake('pixels B')).rejects.toThrow('import unavailable'); expect(await readFile(a.path, 'utf8')).toBe('pixels A'); - await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, busy: false }); + await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }); }); }); diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts index 9c7f7e6a3..0666af84d 100644 --- a/src/core/scene/test/lightfx-bake-host.test.ts +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -73,7 +73,7 @@ describe('LightFXBakeHost', () => { } it('queries protocol and occupancy without reserving, releasing or exposing ownership', async () => { - const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, busy: false }; + const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }; const busy = { ...idle, busy: true }; await expect(host.queryCapabilities()).resolves.toEqual(idle); const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); @@ -91,6 +91,35 @@ describe('LightFXBakeHost', () => { await expect(host.queryCapabilities()).resolves.toEqual(idle); }); + it('bounds native diagnostics, checks exact ownership and retains terminal logs without accepting late callbacks', async () => { + let lateLog!: (message: string) => void; + mockRunnerRun.mockImplementationOnce(async ({ cwd, onLog, onProgress }: { + cwd: string; onLog: (message: string) => void; onProgress: (value: unknown) => void; + }) => { + lateLog = onLog; + for (let index = 0; index < 150; index++) { onLog(`line ${index}`); } + onProgress({ native: [1, 4], file: cwd }); + await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); + }); + const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); + const { operationId } = await host.begin({ ...token, target: 'light-probe', sceneName: 'Probe', textureSources: [], timeoutMs: 120_000 }); + const owner = { ...token, operationId, target: 'light-probe' as const }; + await host.appendInput({ operationId, chunkBase64: Buffer.from('input').toString('base64') }); + await host.run({ operationId }); + const diagnostic = (await host.queryDiagnostics(owner))!; + expect([diagnostic.stage, diagnostic.logs.length, diagnostic.logs[0], diagnostic.logs.at(-1), diagnostic.progress]) + .toEqual(['awaiting-commit', 128, 'line 22', 'line 149', '{"native":[1,4],"file":""}']); + diagnostic.logs.length = 0; + await expect(host.queryDiagnostics({ ...owner, target: 'lightmap' })).resolves.toBeUndefined(); + await expect(host.queryDiagnostics({ ...owner, transactionId: undefined })).resolves.toBeUndefined(); + await expect(host.queryDiagnostics({ ...owner, operationId: 'old' })).resolves.toBeUndefined(); + await host.commit({ operationId }); + lateLog('late callback'); + const completed = (await host.queryDiagnostics(owner))!; + expect([completed.stage, completed.logs.length, completed.logs.at(-1)]).toEqual(['committed', 128, 'line 149']); + await host.releaseSceneOperation(token); + }); + it('reserves before export, rejects missing/wrong ownership and keeps the lease past native commit', async () => { const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); const opts = { target: 'light-probe' as const, sceneName: 'LightProbe', textureSources: [], timeoutMs: 120_000 }; diff --git a/src/core/scene/test/lightmap-bake-info.test.ts b/src/core/scene/test/lightmap-bake-info.test.ts index e293a90c7..73554e0a4 100644 --- a/src/core/scene/test/lightmap-bake-info.test.ts +++ b/src/core/scene/test/lightmap-bake-info.test.ts @@ -2,6 +2,9 @@ const mockGetScene = jest.fn(); const mockQueryLightmapTextureInfo = jest.fn(); const mockMeshRenderer = class MeshRenderer {}; const mockTerrain = class Terrain {}; +jest.mock('../scene-process/service/baking/lightfx/readiness', () => ({ + queryLightmapReadiness: () => ({ version: 1, objects: [] }), +})); jest.mock('cc', () => ({ director: { getScene: mockGetScene }, @@ -81,6 +84,7 @@ describe('LightmapBakeService bake information', () => { await expect(service.queryBakeInfo()).resolves.toEqual({ sceneUrl: 'db://assets/Lightmap.scene', + readiness: { version: 1, objects: [] }, baked: true, meshCount: 2, terrainCount: 1, diff --git a/src/core/scene/test/lightmap-readiness.test.ts b/src/core/scene/test/lightmap-readiness.test.ts new file mode 100644 index 000000000..9f0667e24 --- /dev/null +++ b/src/core/scene/test/lightmap-readiness.test.ts @@ -0,0 +1,42 @@ +jest.mock('cc', () => ({ + gfx: { AttributeName: { ATTR_POSITION: 'position', ATTR_TEX_COORD1: 'uv1' } }, + MeshRenderer: class {}, SkinnedMeshRenderer: class {}, Terrain: class {}, Scene: class {}, + MobilityMode: { Movable: 2 }, +})); +import { MeshRenderer, Terrain, type Scene } from 'cc'; +import { queryLightmapReadiness, validLightmapUV } from '../scene-process/service/baking/lightfx/readiness'; + +describe('Lightmap readiness', () => { + it.each([ + [null, 3, false], [[0, 0], 3, false], [[0, NaN], 1, false], [[Infinity, 0], 1, false], + [[0, 0, 1, 0, 0, 1], 3, true], [new Float32Array([0, 1]), 1, true], [[], 0, false], + ])('validates UV1 %p for %p vertices', (uv, count, expected) => { + expect(validLightmapUV(uv as number[] | null, count as number)).toBe(expected); + }); + + it('reports inherited exclusions, receivers, shadow-only models and invalid UV without mutation', () => { + const renderer = (uuid: string, bakeable = true, uv: number[] | null = [0, 0, 1, 0, 0, 1]) => ({ + uuid, enabled: true, bakeSettings: { bakeable, castShadow: true, lightmapSize: 64 }, + mesh: { struct: { primitives: [{}] }, readAttribute: (_index: number, name: string) => name === 'uv1' ? uv : Array(9).fill(0) }, + }); + const object = (name: string, models: ReturnType[], children: unknown[] = [], mobility = 0) => ({ + name, activeInHierarchy: true, mobility, _objFlags: 0, children, + getComponents: (type: unknown) => type === MeshRenderer ? models : type === Terrain ? [] : [], + }); + const scene = object('scene', [], [ + object('valid', [renderer('a')]), object('shadow', [renderer('b', false, null)]), + object('invalid', [renderer('c', true, null)]), + object('parent', [], [object('child', [renderer('d')])], 2), + { ...object('editor helper', [renderer('internal')], [object('nested helper', [renderer('nested')])]), _objFlags: 1 << 10 }, + ]) as unknown as Scene; + const before = JSON.stringify(scene); + const result = queryLightmapReadiness(scene); + expect(result.objects.map(({ componentUuid, receivesLightmap, castsShadow, issues }) => ({ componentUuid, receivesLightmap, castsShadow, issues }))).toEqual([ + { componentUuid: 'a', receivesLightmap: true, castsShadow: true, issues: ['material-approximation'] }, + { componentUuid: 'b', receivesLightmap: false, castsShadow: true, issues: ['material-approximation'] }, + { componentUuid: 'c', receivesLightmap: true, castsShadow: true, issues: ['invalid-uv1', 'material-approximation'] }, + { componentUuid: 'd', receivesLightmap: false, castsShadow: false, issues: ['movable'] }, + ]); + expect(JSON.stringify(scene)).toBe(before); + }); +}); From 6781dcba38ccb36040c62c4ff6a39046ef115aa7 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 17:51:42 +0800 Subject: [PATCH 27/64] =?UTF-8?q?docs(lighting):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=8E=A2=E9=92=88=E7=BC=96=E8=BE=91=E4=B8=8E=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E6=8E=A5=E5=8F=A3=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index eccb0531b..149db9018 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -39,9 +39,15 @@ const capabilities = await cli.Scene.LightmapBake.queryCapabilities(); // { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, busy: false } ``` -这里的 `resultLifecycleVersion: 1` 包含 Mesh/Terrain 的结果录制目标、空纹理引用、TerrainBlock 恢复刷新及保存基线;`assetVersion: 1` 必须由实际 Node host 的 `lightmapAssetVersion: 1` 确认,保证新 Bake 不覆盖旧纹理版本。旧 host 即使支持 Probe 事务,也可能缺少资产版本保护,此时 Lightmap 查询拒绝返回支持。该能力只覆盖保留资产的 Clear,不承诺 deleteAssets 删除归属、资产 GC 或有归属取消。 +这里的 `resultLifecycleVersion: 1` 包含 Mesh/Terrain 的结果录制目标、空纹理引用、TerrainBlock 恢复刷新及保存基线;`assetVersion: 1` 必须由实际 Node host 的 `lightmapAssetVersion: 1` 确认,保证新 Bake 不覆盖旧纹理版本。旧 host 即使支持 Probe 事务,也可能缺少资产版本保护,此时 Lightmap 查询拒绝返回支持。该能力只覆盖保留资产的 Clear,不承诺 deleteAssets 删除归属或资产 GC;有归属取消另通过 `cancelVersion`/`cancellable` 声明,见下文。 -`busy` 仅为共享宿主的瞬时占用提示,包含导出前预留、原生操作、提交后场景回写及失败恢复;查询不占锁、不释放锁、不返回内部凭据。即使 busy=false,执行入口仍需原子预留,调用方必须处理查询之后发生的并发拒绝。该接口不检查原生 LightFX 可执行文件、场景输入合法性或渲染质量,也不是可恢复的任务状态/百分比/有归属取消接口。新旧 renderer 混用的限制仍见下文。 +`busy` 仅为共享宿主的瞬时占用提示,包含导出前预留、原生操作、提交后场景回写及失败恢复;查询不占锁、不释放锁、不返回内部凭据。即使 busy=false,执行入口仍需原子预留,调用方必须处理查询之后发生的并发拒绝。该接口不检查原生 LightFX 可执行文件、场景输入合法性或渲染质量,也不是持久任务/统一百分比协议。上方示例仅列基础字段;可选取消能力和原生诊断见下文。新旧 renderer 混用的限制仍见下文。 + +### 原生诊断 + +Probe/Lightmap 的 `queryCapabilities()` 和成功 Bake 结果可带 `diagnostics`:`{ version: 1, stage, logs, progress? }`。Scene 只返回本运行实例、对应烘焙类型的当前或最近原生操作,内部 Host 查询校验 operation ID、target 与 transaction ID;不会返回其他场景的日志。没有可用诊断或查询失败时字段可缺省,集成方应降级显示,不能因此把烘焙成功改为失败。 + +Host 最多记住 32 个操作;每个操作保留最近 128 条日志,每条与进度文本上限为 2048 字符,隐藏该操作工作目录和目标资产目录的绝对路径。`progress` 保留 LightFX 原始文本(例如 `Build lighting 25%`),不是统一数值百分比;`stage` 是最近采样的原生阶段,不代替上层 Scene 的成功/取消/恢复状态。进程重启后诊断不保留,不提供持久任务身份或失联事务恢复。 ## MCP 工具 @@ -51,7 +57,7 @@ Scene runtime 先通过内部 `reserveSceneOperation` 取得宿主生成的事 宿主校验事务凭据、目标和动作;错误或已过期的凭据不能开始新的原生烘焙/清理,重复释放旧事务不能释放新持有者。没有场景预留的旧原生 begin 入口仍独占原生操作;旧资产删除入口也会在删除及 Asset DB 刷新期间临时预留。旧 renderer 若完全绕过新增协议执行内存 Clear,并不受此机制保护,集成时必须统一运行产物版本。 -运行实例失联或释放失败时采用 fail-closed:宿主不自动超时放开场景预留,以免暂停的旧实例恢复后与新任务同时写回。此时不要自动重试烘焙;先处理原实例并重启其 Scene host。原生回滚失败时保留恢复备份,不得手工删除以“解除忙状态”。自动失联回收、公开任务状态和按任务归属取消尚未包含在这层协议中;现有 Cancel 仍是共享操作,不应直接当作某个面板私有任务的取消按钮。 +运行实例失联或释放失败时采用 fail-closed:宿主不自动超时放开场景预留,以免暂停的旧实例恢复后与新任务同时写回。此时不要自动重试烘焙;先处理原实例并重启其 Scene host。原生回滚失败时保留恢复备份,不得手工删除以“解除忙状态”。自动失联回收与公共持久任务仍未实现;当前 Cancel 已按本 Scene、烘焙类型和内部操作归属核对,具体契约见“取消烘焙”。 ### 烘焙 Light Probe @@ -91,7 +97,17 @@ Scene runtime 先通过内部 `reserveSceneOperation` 取得宿主生成的事 编辑已启用探针组或其父节点的位置时,CLI 会同步全局采样点和四面体。只有实际采样位置改变才清空旧 SH,避免把旧位置的烘焙结果用于新位置;不会重新生成组件内手工编辑过的采样点。普通节点属性操作和 Gizmo recording 会把受影响的 Scene 数据纳入同一次撤销记录:Undo 恢复旧位置与旧 SH,Redo 恢复新位置与失效状态。保存仍由调用方决定,移动后需要重新烘焙。 -此同步沿用当前引擎的 `localProbe + worldPosition` 约定;完整旋转/缩放与 Gizmo 的 TRS 一致性、重设父级和增删采样点的结构事务仍需独立验收,不等同于所有探针编辑操作已完成。 +此同步沿用当前引擎的 `localProbe + worldPosition` 约定,探针球、范围盒与框选投影也采用相同约定,不额外给局部采样点乘旋转/缩放。祖先旋转/缩放若改变子组世界位置,采样位置同步并使旧 SH 失效;改父级将受影响 Scene 的结果快照放在节点恢复之后,Scene 自身不参与重挂。组件 Undo 替换 probes 数组后重新同步引擎注册引用,避免后续变换再次使用旧数组。 + +### 探针组编辑与显示 + +`Scene.Gizmo` 提供探针 vertex/box 模式查询与切换、生成、全选/取消全选、选中数量、复制/删除以及区域选择接口。选择按实际可见、有效、启用的组统计;隐藏或池化实例换目标时清空旧选择。支持空白或探针球起手框选,Shift/Ctrl/Cmd 追加,追加框选缩小时按按下时的选择基线重新计算。 + +`duplicateSelectedLightProbes()`/`deleteSelectedLightProbes()` 返回 `Promise`,等待 CLI 的 Undo 录制结束后给出实际变更点数;复制副本位于原位置并选中新点。`generateLightProbes()` 只生成采样点,调用方需要为它建立一次 Undo recording,不能把生成当作 GI 烘焙。键盘操作应在场景焦点与正确编辑模式下路由,避免删除节点或修改其他组。 + +凸包外边界、边界法线与内部四面体线框分别绘制,读取 `showConvex`/`showWireframe`;缓存失效覆盖显示参数、采样数据和变换变化。范围逐坐标、复杂拖动/焦点组合、编辑结果保存重开及最终材质显示仍需按场景扩展验收。 + +### Light Probe 烘焙返回结果 成功返回示例: @@ -234,6 +250,12 @@ Probe Bake/Clear 的 `saveScene` 默认是 `true`:完整成功后,当前 Pink 应在场景打开、烘焙完成和清理完成后调用该工具刷新面板。缩略图加载、RGBA 通道切换和时间格式化由 Pink 根据资源 URL/UUID 实现,CLI 不传输图片像素。 +#### 下次烘焙对象检查 + +`queryBakeInfo()` 另返回可选 `readiness: { version: 1, objects }`,与既有绑定结果独立。每项包含组件 UUID、节点名称、mesh/terrain 类型、是否接收贴图/投射阴影、贴图大小以及 `issues`。查询只读,跳过 DontSave 编辑器辅助子树,报告 inactive/Movable 祖先、禁用组件、未参与、缺 mesh、无效 UV1 等条件。 + +接收贴图的 Mesh 在查询及实际导出时检查 UV1 长度是否等于顶点数的两倍、所有值是否有限;检查不包含 UV 重叠或自动展开。蒙皮输出静态网格顶点,不代表当前动画姿态;材质只导出支持的属性;Terrain 基于高度场和世界位置,不额外导出旋转/缩放。调用方可复用 Inspector 的 Bake Settings 修改参与配置,不应把警告、参与标记或查询成功当作画质保证。 + ### 清理 Lightmap 工具名:`scene-clear-lightmap` @@ -373,3 +395,5 @@ Bake 和 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与 随后版本隔离专项补验:三次真实 Mesh Bake 使用不同 URL/UUID,标准/高精度 PNG 的 SHA256 随 Undo/Redo 精确对应旧/新结果,关闭重开保留;未保存新 Bake 时磁盘 Scene 仍引用未变更的旧 PNG。旧平铺资产保持。真实文件事务测试覆盖同名场景多次输出互不覆盖、本次回滚/导入失败不影响旧版本;取消故障不作为新增实机验收,资产删除与历史 GC 仍待专门的归属协议。 Terrain 专项补验:快照恢复数组后,对已有 TerrainBlock 重新绑定对应 lightmap info(无元素时解绑)并让材质失效,避免 Terrain.onRestore 的 valid 快路径保留旧引用。实际单块和持久化 `.terrain` 双块+Mesh 混合场景,Bake/Clear、Undo/Redo、自动/显式保存、关闭重开通过;每个 block 的实际 texture/UV 与序列化结果一致,43 点探针 SH 不变。`bake().terrainCount` 当前是原生输出 block 条目数,`queryBakeInfo().terrainCount` 是拥有绑定的 Terrain 组件数,两者不应直接比较。地形尺寸/高度保存在 `.terrain` 资产,夹具通过 Terrain.saveManage/saveAssetDialog 正式写入,不靠修改内存后只保存 Scene 冒充持久化。 + +编辑与诊断专项补验(同为 macOS arm64/隔离 PinK):两组 16/27 点切组全选、真实复制/删除按钮、空白/球起手及 Shift 追加框选通过;复制→Undo→改父节点保持组件与全局表一致的 43 点,Undo 恢复原 SH、Redo 恢复新位置与失效状态。自身旋转/非均匀缩放的探针球与采样位置一致,祖先变换同步和 Undo 通过。真实 Probe Bake 显示 `Build lighting 100%`;Mesh+双块 Terrain Bake 观察到 `Build lighting 25%` 后取消,前后结果、历史以及 83 个资产/元数据文件哈希一致。另一场景不接收任务日志。上述不包含持久恢复、安全资产回收、跨磁盘失败原子性或其他 OS 的验收。 From 42b28804a3fe510c6ed5756398478d8528b03cbc Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 18:01:38 +0800 Subject: [PATCH 28/64] =?UTF-8?q?fix(scene):=20=E4=BF=AE=E5=A4=8D=E5=85=89?= =?UTF-8?q?=E7=85=A7=E6=8E=A2=E9=92=88=E7=BC=96=E8=BE=91=E5=89=8D=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E5=B7=A5=E5=85=B7=E7=8A=B6=E6=80=81=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保存工具名称和视图模式,在切换工具后设置目标模式,确保退出探针编辑、切换包围盒编辑和隐藏最后一个探针组时恢复原状态。 使用真实 TransformToolData 补充 7 项回归测试,覆盖各工具模式、重复进入及自动退出。类型检查、ESLint 和 40 组共 385 项测试通过。 --- .../components/light-probe-group/index.ts | 11 ++-- .../scene/test/light-probe-edit-gizmo.test.ts | 56 ++++++++++++++++++- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts b/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts index c78d0ded0..eae7632ea 100644 --- a/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts +++ b/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts @@ -14,6 +14,7 @@ import { ServiceEvents } from '../../../core/global-events'; import { ProbeSelection, probeSelectionEvents } from './selection'; import type { GizmoMouseEvent } from '../../utils/defines'; import type { CameraService } from '../../../camera'; +import type { TransformToolDataViewMode } from '../../transform-tool'; // 探针数量超过该阈值时只画包围盒/线框、不逐个建球,避免海量节点 const MAX_PROBE_DOTS = 4096; @@ -30,7 +31,7 @@ const tempDelta = new Vec3(); type EditMode = 'none' | 'vertex' | 'box'; let editMode: EditMode = 'none'; -let previousTool: string | undefined; +let previousTool: { name: string; viewMode: TransformToolDataViewMode } | undefined; const instances = new Set(); function activeGroups(): LightProbeGroupComponentGizmo[] { @@ -43,13 +44,15 @@ function changeEditMode(mode: EditMode): void { editMode = mode; const gizmo = Service.Gizmo; if (mode === 'vertex') { - previousTool = gizmo.transformToolName; - gizmo.transformToolData.viewMode = 'select'; + previousTool = { name: gizmo.transformToolName, viewMode: gizmo.transformToolData.viewMode }; gizmo.transformToolName = 'view'; + // Changing the tool toggles its view mode, so apply the intended mode last. + gizmo.transformToolData.viewMode = 'select'; } else if (previousTool !== undefined) { const restore = previousTool; previousTool = undefined; - gizmo.transformToolName = restore; + gizmo.transformToolName = restore.name; + gizmo.transformToolData.viewMode = restore.viewMode; } for (const instance of instances) { instance.modeChanged(); } ServiceEvents.broadcast('scene:light-probe-edit-mode-changed', mode === 'vertex'); diff --git a/src/core/scene/test/light-probe-edit-gizmo.test.ts b/src/core/scene/test/light-probe-edit-gizmo.test.ts index 305683a64..92c1a984f 100644 --- a/src/core/scene/test/light-probe-edit-gizmo.test.ts +++ b/src/core/scene/test/light-probe-edit-gizmo.test.ts @@ -1,5 +1,11 @@ +import { TransformToolData, type TransformToolDataToolNameType, type TransformToolDataViewMode } from '../scene-process/service/gizmo/transform-tool'; + const mockService = { - Gizmo: { transformToolName: 'position', transformToolData: { viewMode: 'select' } }, + Gizmo: { + transformToolData: new TransformToolData(), + get transformToolName() { return this.transformToolData.toolName; }, + set transformToolName(value: TransformToolDataToolNameType) { this.transformToolData.toolName = value; }, + }, Engine: { repaintInEditMode: jest.fn() }, Undo: { beginRecording: jest.fn(() => 'record'), endRecording: jest.fn(async (_id: string) => {}) }, }; @@ -53,12 +59,60 @@ function group(uuid: string, count: number) { return gizmo; } +beforeEach(() => { + mockService.Gizmo.transformToolData = new TransformToolData(); +}); + afterEach(() => { for (const gizmo of created.splice(0)) { gizmo.onHide(); gizmo.onDestroy(); } + methods.changeEditMode('none'); jest.clearAllMocks(); }); describe('Probe editing pooled Gizmos', () => { + it.each<[TransformToolDataToolNameType, TransformToolDataViewMode]>([ + ['position', 'select'], + ['rotation', 'select'], + ['scale', 'select'], + ['rect', 'select'], + ['view', 'select'], + ['view', 'view'], + ])('uses probe selection and restores the original %s/%s tool state', (toolName, viewMode) => { + group('a', 4); + const tool = mockService.Gizmo.transformToolData; + tool.toolName = toolName; + tool.viewMode = viewMode; + + methods.changeEditMode('vertex'); + const during = { toolName: tool.toolName, viewMode: tool.viewMode }; + // Repeating the current mode must not replace the original tool snapshot. + methods.changeEditMode('vertex'); + methods.changeEditMode('none'); + + expect({ during, after: { toolName: tool.toolName, viewMode: tool.viewMode } }).toEqual({ + during: { toolName: 'view', viewMode: 'select' }, + after: { toolName, viewMode }, + }); + }); + + it('restores browsing when switching to box mode and when the last probe group hides', () => { + const gizmo = group('a', 4); + const tool = mockService.Gizmo.transformToolData; + tool.toolName = 'view'; + tool.viewMode = 'view'; + + methods.changeEditMode('vertex'); + methods.changeEditMode('box'); + const box = { mode: methods.getEditMode(), toolName: tool.toolName, viewMode: tool.viewMode }; + methods.changeEditMode('vertex'); + gizmo.onHide(); + + expect({ box, hidden: { mode: methods.getEditMode(), toolName: tool.toolName, viewMode: tool.viewMode } }).toEqual({ + box: { mode: 'box', toolName: 'view', viewMode: 'view' }, + hidden: { mode: 'none', toolName: 'view', viewMode: 'view' }, + }); + }); + it('counts only visible valid groups and clears a reused target with the same probe count', () => { const first = group('a', 32); const second = group('b', 32); From c98d5d78e99a44275a7b27f27c811fb5b8aff5d2 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 18:17:05 +0800 Subject: [PATCH 29/64] =?UTF-8?q?fix(lighting):=20=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E4=B8=8E=E6=8F=90=E4=BA=A4=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E7=83=98=E7=84=99=E7=BB=93=E6=9E=9C=E9=94=99?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 21 ++- .../service/baking/lightfx/saved-recording.ts | 42 +++-- .../scene-process/service/light-probe-bake.ts | 26 +-- .../scene-process/service/lightmap-bake.ts | 14 +- .../test/lightfx-result-failures.test.ts | 150 ++++++++++++++++++ .../test/lightfx-saved-recording.test.ts | 57 ++++--- .../test/lightmap-result-recording.test.ts | 14 +- 7 files changed, 265 insertions(+), 59 deletions(-) create mode 100644 src/core/scene/test/lightfx-result-failures.test.ts diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 149db9018..cb0a18c17 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -93,7 +93,7 @@ Scene runtime 先通过内部 `reserveSceneOperation` 取得宿主生成的事 | `saveScene` | boolean | `true` | | `timeoutMs` | 1000–3600000 ms | 600000 ms | -所有参数均可选,未传入时使用场景当前值。`giScale`、`giSamples` 和 `bounces` 参与 LightFX 计算;`reduceRinging`、`showWireframe`、`showConvex` 和 `lightProbeSphereVolume` 用于烘焙结果后处理或编辑器显示。烘焙成功后,本次的有效参数与 SH 结果作为同一次 Undo 操作写回 `LightProbeInfo`;烘焙失败或取消时保留原场景配置。 +所有参数均可选,未传入时使用场景当前值。`giScale`、`giSamples` 和 `bounces` 参与 LightFX 计算;`reduceRinging`、`showWireframe`、`showConvex` 和 `lightProbeSphereVolume` 用于烘焙结果后处理或编辑器显示。烘焙成功后,本次的有效参数与 SH 结果作为同一次 Undo 操作写回 `LightProbeInfo`;计算失败、提交未确认或取消胜出时不应用结果。结果已录制后的保存失败保留新结果,详见下文。 编辑已启用探针组或其父节点的位置时,CLI 会同步全局采样点和四面体。只有实际采样位置改变才清空旧 SH,避免把旧位置的烘焙结果用于新位置;不会重新生成组件内手工编辑过的采样点。普通节点属性操作和 Gizmo recording 会把受影响的 Scene 数据纳入同一次撤销记录:Undo 恢复旧位置与旧 SH,Redo 恢复新位置与失效状态。保存仍由调用方决定,移动后需要重新烘焙。 @@ -145,7 +145,18 @@ Scene runtime 先通过内部 `reserveSceneOperation` 取得宿主生成的事 该操作清除当前场景全部探针的烘焙结果,通知引擎刷新,并作为一次 Undo 操作记录。成功结果中的 `probeCount` 表示处理的探针数量。 -Probe Bake/Clear 的 `saveScene` 默认是 `true`:完整成功后,当前结果作为 Undo 的已保存基线;Undo 回到旧结果会变脏,Redo 回到已保存结果恢复干净。显式传 `false` 时只修改内存并保留 dirty,调用方需要另行保存。保存失败不提交新录制;原生提交失败、提交期间发生其他编辑或历史重置时,不额外把当前历史标成已保存。这不代表跨磁盘与原生资产提交的失败回滚已经具备完整原子性。 +Probe/Lightmap Bake/Clear 的 `saveScene` 默认是 `true`:完整成功后,当前结果作为 Undo 的已保存基线;Undo 回到旧结果会变脏,Redo 回到已保存结果恢复干净。显式传 `false` 时只修改内存并保留 dirty,调用方需要另行保存。 + +### 提交与保存失败 + +Bake 按「确认原生产物提交 → 应用场景结果 → 完成 Undo 录制 → 可选保存」执行;Clear 无原生提交,先完成结果录制再保存。Host 的 `committed` 只表示产物不再被取消/超时/rollback 删除,不代表 Scene 已应用或保存,也不代表整个任务成功。 + +- 原生提交拒绝或回应丢失:不应用结果、不创建结果历史、不保存场景。Host 若实际上已提交,则新版本可能成为未引用资产;保留它,不强删、不自动重新 Bake。 +- 结果应用或录制前失败:恢复旧内存,不保存。已确认提交的产物仍保留,避免把不可逆的资产提交误当成可回滚事务。 +- Undo 已入栈后的保存失败/回应丢失:抛出包含 `LightFX result retained` 和原始原因的错误,**保留当前结果、Undo 和产物**。保存请求可能未写盘,也可能已写盘但没有返回确认;不能通过自动恢复旧内存或删除贴图来猜测磁盘状态。调用方应刷新实际结果,允许用户检查后重新保存或 Undo,不要把失败解释为“场景未改变”。 +- 失败时不会额外标记已保存。保存尚未写盘时结果保持 dirty;若保存已确认完成后才发生外层回应错误,内存与已保存结果相同,可以保持 clean。dirty 不是保存失败原因或磁盘写入状态的唯一证据。 + +这保证正常运行实例中不先发布可被原生回滚删除的场景引用,但不是磁盘/Terrain/资产的多文件原子事务。进程崩溃恢复、任意并发编辑与保存协调、未引用版本安全回收仍需独立协议,不由此提交顺序承诺。 ### 烘焙 Lightmap @@ -338,8 +349,8 @@ LFX_Terrain_0000.png - 旧版平铺目录中的 PNG/`.meta` 原样保留,不自动迁移、不复用其 UUID。调用方必须使用返回的 textureUrls 或真实绑定查询,不拼接固定文件路径。 - 历史版本暂不自动回收,因此磁盘占用随烘焙次数增加。不能只按“当前场景没绑定”删除旧版本,Undo、其他场景或磁盘已保存版本可能仍在引用。 - 导入后将 `fixAlphaTransparencyArtifacts` 设置为 `false`,再加载 Texture2D 子资源并绑定。 -- 资产导入、组件绑定或场景保存失败时,回滚本次新目录并尝试恢复组件绑定和场景全局标记,旧版本目录不受影响。 -- 成功、失败、取消和超时都会清理本次 LightFX workspace。 +- 原生提交确认前的导入/加载失败尝试回滚本次新目录;提交确认后不再删除产物。应用失败恢复旧绑定,保存失败保留已录制结果,规则见“提交与保存失败”。旧版本目录不受影响。 +- 成功、失败、取消和超时进入 workspace 清理;回滚或 Asset DB 刷新失败时保留备份和互斥以便恢复,不能宣称所有错误都会完成清理。 ## Creator 互操作说明 @@ -372,7 +383,7 @@ LightFX 当前可能输出 Creator 历史协议版本。解析器只接受已知 - 已有另一个 LightFX 任务运行。 - 当前可见场景尚未加载完成,或同时存在多个可见的场景渲染器。 -Bake 和 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与结果修改的 MeshRenderer/Terrain 组件(同一 Terrain 多 block 去重)及 Scene 标记,不能只录制不递归的 Scene 根节点;旧引擎缺类型的空纹理引用也会保留在快照中。成功自动保存后以该记录作为保存点;saveScene:false 不隐式保存场景。场景结果提交失败时尝试恢复原组件数据和全局标记;Lightmap 资产提交失败时还会恢复原 PNG 与 `.meta`。 +Bake 和 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与结果修改的 MeshRenderer/Terrain 组件(同一 Terrain 多 block 去重)及 Scene 标记,不能只录制不递归的 Scene 根节点;旧引擎缺类型的空纹理引用也会保留在快照中。成功自动保存后以该记录作为保存点;saveScene:false 不隐式保存场景。失败必须区分原生提交前、结果应用中和结果录制后保存阶段,不能对所有错误统一恢复绑定或删除资产,详见“提交与保存失败”。 绑定历史与资产版本分别保留:新版本输出不会覆盖旧 PNG,Undo 通过旧 UUID 恢复旧纹理/UV/场景标记。此前旧版本 CLI 已覆盖丢失的像素无法靠此修复找回。`deleteAssets:true` 仍会删除同 sceneName 的整个 lightmap 目录,包括所有版本;不属于可恢复绑定的保留资产验证范围,调用方不可据此假定删除可撤销或跨同名场景安全。 diff --git a/src/core/scene/scene-process/service/baking/lightfx/saved-recording.ts b/src/core/scene/scene-process/service/baking/lightfx/saved-recording.ts index 554456a7f..72e4a3337 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/saved-recording.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/saved-recording.ts @@ -1,21 +1,35 @@ import type { IUndoService } from '../../../../common'; -/** Completes a result recording without treating a later edit as part of its saved result. */ +/** The result is already in Undo history and must not be silently restored by the caller. */ +export class LightFXResultRetainedError extends Error { + constructor(stage: 'recording' | 'save', cause: unknown) { + super(`LightFX result retained in the scene and Undo history; ${stage} was not confirmed. Check the scene before saving again or undoing. ${cause instanceof Error ? cause.message : String(cause)}`); + this.name = 'LightFXResultRetainedError'; + } +} + +/** Record before attempting I/O: a rejected save may already have written the scene to disk. */ export async function finishSavedLightFXRecording( - undo: Pick, + undo: Pick, recordingId: string, save?: () => Promise, - commit?: () => Promise, ): Promise { - if (save) await save(); - const saved = save ? undo.createCheckpoint() : undefined; - await undo.endRecording(recordingId); - if (commit) await commit(); - if (!saved) return; - const current = undo.createCheckpoint(); - // Editor.save marks the previous history entry because recording is still - // open. Advance that mark only for this exact committed recording. A no-op - // recording needs no new mark; edits or history resets during commit do not - // belong to the saved result and must remain dirty. - if (current.commandId === recordingId && current.generation === saved.generation) undo.markSaved(); + const before = undo.createCheckpoint(); + try { + await undo.endRecording(recordingId); + } catch (error) { + const current = undo.createCheckpoint(); + if (current.commandId === recordingId && current.generation === before.generation) { + throw new LightFXResultRetainedError('recording', error); + } + throw error; + } + if (save) { + try { + // Editor.save now marks the completed recording, not its predecessor. + await save(); + } catch (error) { + throw new LightFXResultRetainedError('save', error); + } + } } diff --git a/src/core/scene/scene-process/service/light-probe-bake.ts b/src/core/scene/scene-process/service/light-probe-bake.ts index d8f562f91..3016fb8fe 100644 --- a/src/core/scene/scene-process/service/light-probe-bake.ts +++ b/src/core/scene/scene-process/service/light-probe-bake.ts @@ -11,7 +11,7 @@ import { lightFXCoordinator, LightFXBakeOutput } from './baking/lightfx/baker'; import { createDefaultLightFXSettings } from './baking/lightfx/settings'; import { lightFXSceneOperation } from './baking/lightfx/scene-operation'; import { lightFXBakeHost } from './baking/lightfx/host'; -import { finishSavedLightFXRecording } from './baking/lightfx/saved-recording'; +import { finishSavedLightFXRecording, LightFXResultRetainedError } from './baking/lightfx/saved-recording'; import { BaseService, register, Service } from './core'; interface ProbeSnapshot { @@ -72,22 +72,27 @@ export class LightProbeBakeService extends BaseService imple const previous = this.snapshot(probes); let output: LightFXBakeOutput | undefined; + let nativeCommitted = false; + let applying = false; this.broadcast('lightfx:bake-start', 'light-probe'); try { output = await lightFXCoordinator.bake(scene, 'light-probe', settings, options.timeoutMs ?? 600_000); this.validateResult(probes, output); + await lightFXCoordinator.commit(output.operationId); + nativeCommitted = true; const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Bake light probes' }); try { + applying = true; this.applySettings(info, settingsToApply); this.applyResult(probes, output); info.onProbeBakeFinished(); await Service.Engine.repaintInEditMode(); await finishSavedLightFXRecording(Service.Undo, undo, - options.saveScene !== false ? () => Service.Editor.save({}) : undefined, - () => lightFXCoordinator.commit(output!.operationId)); + options.saveScene !== false ? () => Service.Editor.save({}) : undefined); + applying = false; } catch (error) { - Service.Undo.cancelRecording(undo); + if (!(error instanceof LightFXResultRetainedError)) Service.Undo.cancelRecording(undo); throw error; } @@ -100,11 +105,13 @@ export class LightProbeBakeService extends BaseService imple diagnostics: await lightFXCoordinator.queryDiagnostics?.('light-probe'), }; } catch (error) { - if (output) await lightFXCoordinator.rollback(output.operationId).catch(() => undefined); - this.restore(probes, previous); - this.applySettings(info, previousSettings); - info.onProbeBakeFinished(); - await Service.Engine.repaintInEditMode(); + if (output && !nativeCommitted) await lightFXCoordinator.rollback(output.operationId).catch(() => undefined); + if (applying && !(error instanceof LightFXResultRetainedError)) { + this.restore(probes, previous); + this.applySettings(info, previousSettings); + info.onProbeBakeFinished(); + await Service.Engine.repaintInEditMode(); + } this.broadcast('lightfx:bake-end', 'light-probe', this.errorMessage(error)); throw error; } @@ -128,6 +135,7 @@ export class LightProbeBakeService extends BaseService imple options.saveScene !== false ? () => Service.Editor.save({}) : undefined); return { probeCount: probes.length }; } catch (error) { + if (error instanceof LightFXResultRetainedError) throw error; Service.Undo.cancelRecording(undo); this.restore(probes, previous); info.onProbeBakeFinished(); diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index 88eba4477..eacce5d55 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -9,7 +9,7 @@ import type { LightFXBakeOutput } from './baking/lightfx/baker'; import { lightFXBakeHost } from './baking/lightfx/host'; import { createDefaultLightFXSettings } from './baking/lightfx/settings'; import { lightFXSceneOperation } from './baking/lightfx/scene-operation'; -import { finishSavedLightFXRecording } from './baking/lightfx/saved-recording'; +import { finishSavedLightFXRecording, LightFXResultRetainedError } from './baking/lightfx/saved-recording'; import { BaseService, register, Service } from './core'; import { loadPreviewAsset } from './preview/asset-reload'; import { queryLightmapReadiness } from './baking/lightfx/readiness'; @@ -61,6 +61,7 @@ export class LightmapBakeService extends BaseService impleme const timeoutMs = options.timeoutMs ?? 600_000; let output: LightFXBakeOutput | undefined; + let nativeCommitted = false; this.broadcast('lightfx:bake-start', 'lightmap'); try { output = await lightFXCoordinator.bake(scene, 'lightmap', settings, timeoutMs); @@ -70,6 +71,10 @@ export class LightmapBakeService extends BaseService impleme const targetUrl = `db://assets/${scene.name}/lightmap`; const textures = await this.loadOutputTextures(output, targetUrl, timeoutMs); + // No scene/history/disk reference may precede the host's decision to retain assets. + // An unconfirmed commit can leave an orphan version, never a dangling scene binding. + await lightFXCoordinator.commit(output.operationId); + nativeCommitted = true; const previousBindings = this.snapshotBindings(output); const previousHighp = (scene.globals as any).bakedWithHighpLightmap; const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; @@ -83,9 +88,9 @@ export class LightmapBakeService extends BaseService impleme (scene.globals as any).bakedWithStationaryMainLight = output.stationaryMainLight; await Service.Engine.repaintInEditMode(); await finishSavedLightFXRecording(Service.Undo, undo, - options.saveScene !== false ? () => Service.Editor.save({}) : undefined, - () => lightFXCoordinator.commit(output!.operationId)); + options.saveScene !== false ? () => Service.Editor.save({}) : undefined); } catch (error) { + if (error instanceof LightFXResultRetainedError) throw error; this.restoreBindings(previousBindings); (scene.globals as any).bakedWithHighpLightmap = previousHighp; (scene.globals as any).bakedWithStationaryMainLight = previousStationary; @@ -103,7 +108,7 @@ export class LightmapBakeService extends BaseService impleme diagnostics: await lightFXCoordinator.queryDiagnostics?.('lightmap'), }; } catch (error) { - if (output) await lightFXCoordinator.rollback(output.operationId).catch((rollbackError) => { + if (output && !nativeCommitted) await lightFXCoordinator.rollback(output.operationId).catch((rollbackError) => { console.error('[LightFX] Failed to roll back lightmap assets:', rollbackError); }); this.broadcast('lightfx:bake-end', 'lightmap', this.errorMessage(error)); @@ -175,6 +180,7 @@ export class LightmapBakeService extends BaseService impleme await finishSavedLightFXRecording(Service.Undo, undo, options.saveScene !== false ? () => Service.Editor.save({}) : undefined); } catch (error) { + if (error instanceof LightFXResultRetainedError) throw error; Service.Undo.cancelRecording(undo); this.restoreBindings(bindings); (scene.globals as any).bakedWithHighpLightmap = previousHighp; diff --git a/src/core/scene/test/lightfx-result-failures.test.ts b/src/core/scene/test/lightfx-result-failures.test.ts new file mode 100644 index 000000000..c6f013139 --- /dev/null +++ b/src/core/scene/test/lightfx-result-failures.test.ts @@ -0,0 +1,150 @@ +const mockGetScene = jest.fn(); +const mockMeshRenderer = class MeshRenderer {}; +const mockTerrain = class Terrain {}; +class MockVec3 { + constructor(public x = 0, public y = 0, public z = 0) {} + clone() { return new MockVec3(this.x, this.y, this.z); } + set(x: number | MockVec3, y?: number, z?: number) { + Object.assign(this, typeof x === 'number' ? { x, y, z } : x); + } +} +const mockBake = jest.fn(), mockCommit = jest.fn(), mockRollback = jest.fn(); +const mockSave = jest.fn(), mockRepaint = jest.fn(); +const mockUndo = { beginRecording: jest.fn(), endRecording: jest.fn(), cancelRecording: jest.fn(), createCheckpoint: jest.fn() }; +jest.mock('cc', () => ({ director: { getScene: mockGetScene }, MeshRenderer: mockMeshRenderer, Terrain: mockTerrain, + Vec3: MockVec3, SH: { getBasisCount: () => 9 } })); +jest.mock('../scene-process/service/core', () => ({ + BaseService: class { broadcast() {} }, register: () => () => undefined, + Service: { Undo: mockUndo, Editor: { save: mockSave }, Engine: { repaintInEditMode: mockRepaint } }, +})); +jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { + bake: mockBake, commit: mockCommit, rollback: mockRollback, +} })); +jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { + reserveSceneOperation: async () => ({ transactionId: 'owner' }), releaseSceneOperation: async () => undefined, +} })); +jest.mock('../scene-process/service/baking/lightfx/settings', () => ({ createDefaultLightFXSettings: () => ({}) })); +jest.mock('../scene-process/service/preview/asset-reload', () => ({ loadPreviewAsset: jest.fn() })); +jest.mock('../scene-process/rpc', () => ({ Rpc: { getInstance: jest.fn() } })); + +import { LightmapBakeService } from '../scene-process/service/lightmap-bake'; +import { LightProbeBakeService } from '../scene-process/service/light-probe-bake'; +import { SceneUndoManager } from '../scene-process/service/undo/scene-undo-manager'; + +function fixture(target: 'probe' | 'lightmap') { + const events: string[] = []; + const oldTexture = { uuid: 'old' }, texture = { uuid: 'new' }; + let assets = ['old', 'new']; + let committed = false; + const model = { uuid: 'mesh', node: {}, bakeSettings: { texture: oldTexture as { uuid: string } | null, + uvParam: { x: 1, y: 2, z: 3, w: 4, clone() { return { x: this.x, y: this.y, z: this.z, w: this.w }; } } }, + _updateLightmap(value: { uuid: string } | null, x: number, y: number, z: number, w: number) { + this.bakeSettings.texture = value; + Object.assign(this.bakeSettings.uvParam, { x, y, z, w }); + } }; + const probes = Array.from({ length: 4 }, (_, x) => ({ position: new MockVec3(x), normal: new MockVec3(), coefficients: [new MockVec3(1)] })); + const info = { data: { probes }, giScale: 1, onProbeBakeFinished() {}, onProbeBakeCleared() { probes.forEach(p => { p.coefficients = []; }); } }; + const scene = { uuid: 'scene', name: 'test', globals: { lightProbeInfo: info, bakedWithHighpLightmap: false, bakedWithStationaryMainLight: false }, + children: [], getComponents: (type: unknown) => type === mockMeshRenderer ? [model] : [], + }; + const read = () => ({ texture: model.bakeSettings.texture?.uuid ?? null, uv: model.bakeSettings.uvParam.clone(), + highp: scene.globals.bakedWithHighpLightmap, stationary: scene.globals.bakedWithStationaryMainLight, + giScale: info.giScale, probes: probes.map(p => ({ normal: p.normal.clone(), coefficients: p.coefficients.map(c => c.clone()) })) }); + let disk = read(); + const manager = new SceneUndoManager({ snapshotAdapter: { + capture: () => new Map([['scene', read()]]), + equals: (a, b) => JSON.stringify(a.get('scene')) === JSON.stringify(b.get('scene')), + apply: data => { + const state = data.get('scene') as ReturnType; + model._updateLightmap(state.texture ? { uuid: state.texture } : null, state.uv.x, state.uv.y, state.uv.z, state.uv.w); + scene.globals.bakedWithHighpLightmap = state.highp; + scene.globals.bakedWithStationaryMainLight = state.stationary; + info.giScale = state.giScale; + state.probes.forEach((p, i) => { probes[i].normal.set(p.normal); probes[i].coefficients = p.coefficients.map(c => c.clone()); }); + return { success: true }; + }, + } }); + mockUndo.beginRecording.mockImplementation(uuids => manager.beginRecording(uuids)); + mockUndo.endRecording.mockImplementation(async id => { events.push('record'); await manager.endRecording(id); }); + mockUndo.cancelRecording.mockImplementation(id => manager.cancelRecording(id)); + mockUndo.createCheckpoint.mockImplementation(() => manager.createCheckpoint()); + const save = async () => { events.push('save'); disk = read(); manager.markSaved(); }; + mockSave.mockImplementation(save); + mockCommit.mockImplementation(async () => { events.push('commit'); committed = true; }); + mockRollback.mockImplementation(async () => { if (committed) throw new Error('already committed'); assets = ['old']; }); + mockBake.mockResolvedValue({ models: [model], terrains: [], operationId: 'operation', stationaryMainLight: true, textureUrls: [], result: { + meshes: [{ id: 0, index: 0, offset: [0.1, 0.2], scale: [0.3, 0.4] }], terrains: [], + probes: probes.map(p => ({ position: [p.position.x, 0, 0], normal: [0, 1, 0], coefficients: new Array(27).fill(2) })), + } }); + mockGetScene.mockReturnValue(scene); + const service = target === 'probe' ? new LightProbeBakeService() : new LightmapBakeService(); + jest.spyOn(service as any, 'querySceneUrl').mockResolvedValue('db://assets/test.scene'); + if (service instanceof LightmapBakeService) jest.spyOn(service as any, 'loadOutputTextures').mockResolvedValue(new Map([['mesh:0', texture]])); + return { service, manager, read, disk: () => disk, assets: () => assets, events, save, + commit: async () => { committed = true; }, bake: () => service.bake({ giScale: 2, highp: true }), old: read() }; +} + +describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', target => { + beforeEach(() => { jest.resetAllMocks(); }); + it('confirms asset retention before recording or saving', async () => { + const f = fixture(target); + await f.bake(); + expect({ events: f.events, disk: f.disk(), dirty: f.manager.isDirty() }).toEqual({ events: ['commit', 'record', 'save'], disk: f.read(), dirty: false }); + }); + + it.each([false, true])('does not mutate scene, disk or history on commit failure (host committed=%s)', async committed => { + const f = fixture(target); + mockCommit.mockImplementationOnce(async () => { if (committed) await f.commit(); throw new Error('commit response failed'); }); + await expect(f.bake()).rejects.toThrow('commit response failed'); + expect({ memory: f.read(), disk: f.disk(), assets: f.assets(), undo: f.manager.canUndo() }).toEqual({ + memory: f.old, disk: f.old, assets: committed ? ['old', 'new'] : ['old'], undo: false, + }); + expect(mockSave).not.toHaveBeenCalled(); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + }); + + it.each(['bake', 'clear'] as const)('retains %s after pre-write save failure, supports Undo/Redo and retry', async action => { + const f = fixture(target); + mockSave.mockRejectedValueOnce(new Error('disk unavailable')); + await expect(action === 'bake' ? f.bake() : f.service.clearBake()).rejects.toThrow('result retained'); + const result = f.read(); + expect(result).not.toEqual(f.old); + expect({ disk: f.disk(), assets: f.assets(), dirty: f.manager.isDirty() }).toEqual({ disk: f.old, assets: ['old', 'new'], dirty: true }); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(mockRollback).not.toHaveBeenCalled(); + await f.manager.undo(); + expect(f.read()).toEqual(f.old); + await f.manager.redo(); + expect(f.read()).toEqual(result); + await f.save(); + expect({ disk: f.disk(), dirty: f.manager.isDirty() }).toEqual({ disk: result, dirty: false }); + }); + + it.each(['bake', 'clear'] as const)('retains %s when disk was written but its response failed', async action => { + const f = fixture(target); + mockSave.mockImplementationOnce(async () => { await f.save(); throw new Error('save response lost'); }); + await expect(action === 'bake' ? f.bake() : f.service.clearBake()).rejects.toThrow('save response lost'); + const result = f.read(); + expect(f.disk()).toEqual(result); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(mockRollback).not.toHaveBeenCalled(); + await f.manager.undo(); + expect(f.read()).toEqual(f.old); + expect(f.manager.isDirty()).toBe(true); + await f.manager.redo(); + expect({ memory: f.read(), disk: f.disk(), assets: f.assets(), dirty: f.manager.isDirty() }).toEqual({ + memory: result, disk: result, assets: ['old', 'new'], dirty: false, + }); + }); + + it('restores an application failure without deleting committed assets or creating history', async () => { + const f = fixture(target); + mockRepaint.mockRejectedValueOnce(new Error('application repaint failed')); + await expect(f.bake()).rejects.toThrow('application repaint failed'); + expect({ memory: f.read(), disk: f.disk(), assets: f.assets(), undo: f.manager.canUndo() }).toEqual({ + memory: f.old, disk: f.old, assets: ['old', 'new'], undo: false, + }); + expect(mockSave).not.toHaveBeenCalled(); + expect(mockRollback).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/scene/test/lightfx-saved-recording.test.ts b/src/core/scene/test/lightfx-saved-recording.test.ts index 38d10a92b..e4de4022c 100644 --- a/src/core/scene/test/lightfx-saved-recording.test.ts +++ b/src/core/scene/test/lightfx-saved-recording.test.ts @@ -1,5 +1,5 @@ import { SceneUndoManager } from '../scene-process/service/undo/scene-undo-manager'; -import { finishSavedLightFXRecording } from '../scene-process/service/baking/lightfx/saved-recording'; +import { finishSavedLightFXRecording, LightFXResultRetainedError } from '../scene-process/service/baking/lightfx/saved-recording'; function fixture() { let data = 'old SH'; @@ -24,8 +24,8 @@ describe('LightFX result save baseline', () => { it.each(['new baked SH', ''])('marks the completed saved result, Undo becomes dirty and Redo returns to saved (%s)', async value => { const f = fixture(); const id = f.record(value); - await finishSavedLightFXRecording(f.undo, id, f.save, async () => { f.events.push('commit'); }); - expect({ ...f.read(), events: f.events }).toEqual({ data: value, disk: value, dirty: false, events: ['save', 'record', 'commit', 'mark'] }); + await finishSavedLightFXRecording(f.undo, id, f.save); + expect({ ...f.read(), events: f.events }).toEqual({ data: value, disk: value, dirty: false, events: ['record', 'save'] }); await f.manager.undo(); expect(f.read()).toEqual({ data: 'old SH', disk: value, dirty: true }); await f.manager.redo(); @@ -38,36 +38,53 @@ describe('LightFX result save baseline', () => { expect({ ...f.read(), events: f.events }).toEqual({ data: 'new SH', disk: 'old SH', dirty: true, events: ['record'] }); }); - it('does not commit a recording if saving fails', async () => { + it('retains the result and Undo if saving fails before writing disk', async () => { const f = fixture(); const id = f.record('new SH'); await expect(finishSavedLightFXRecording(f.undo, id, async () => { throw new Error('save failed'); })).rejects.toThrow('save failed'); - expect(f.events).toEqual([]); - expect(f.manager.canUndo()).toBe(false); - f.manager.cancelRecording(id); + expect(f.read()).toEqual({ data: 'new SH', disk: 'old SH', dirty: true }); + expect(f.manager.hasActiveRecording()).toBe(false); + await f.manager.undo(); + expect(f.read()).toEqual({ data: 'old SH', disk: 'old SH', dirty: false }); + await f.manager.redo(); + expect(f.read()).toEqual({ data: 'new SH', disk: 'old SH', dirty: true }); }); - it('does not mark a failed native commit as saved', async () => { + it('retains the saved result if the response fails after writing disk', async () => { const f = fixture(); - await expect(finishSavedLightFXRecording(f.undo, f.record('new SH'), f.save, async () => { throw new Error('commit failed'); })).rejects.toThrow('commit failed'); - expect(f.undo.markSaved).not.toHaveBeenCalled(); - expect(f.manager.isDirty()).toBe(true); + await expect(finishSavedLightFXRecording(f.undo, f.record('new SH'), async () => { + await f.save(); + throw new Error('response lost'); + })).rejects.toBeInstanceOf(LightFXResultRetainedError); + expect(f.read()).toEqual({ data: 'new SH', disk: 'new SH', dirty: false }); + await f.manager.undo(); + expect(f.read()).toEqual({ data: 'old SH', disk: 'new SH', dirty: true }); + await f.manager.redo(); + expect(f.read()).toEqual({ data: 'new SH', disk: 'new SH', dirty: false }); }); - it('does not mark an edit made during the asynchronous native commit as saved', async () => { + it('retains a recording if endRecording reports failure after pushing history', async () => { const f = fixture(); - await finishSavedLightFXRecording(f.undo, f.record('new SH'), f.save, async () => { - await f.manager.endRecording(f.record('later edit')); - }); - expect(f.undo.markSaved).not.toHaveBeenCalled(); - expect(f.read()).toEqual({ data: 'later edit', disk: 'new SH', dirty: true }); + const undo = { ...f.undo, endRecording: async (id: string) => { + await f.undo.endRecording(id); + throw new Error('notification failed'); + } }; + await expect(finishSavedLightFXRecording(undo, f.record('new SH'), f.save)).rejects.toBeInstanceOf(LightFXResultRetainedError); + expect(f.events).toEqual(['record']); + expect(f.read()).toEqual({ data: 'new SH', disk: 'old SH', dirty: true }); + }); + + it('does not attempt saving when history cannot be captured', async () => { + const f = fixture(); + const undo = { ...f.undo, endRecording: async () => { throw new Error('capture failed'); } }; + await expect(finishSavedLightFXRecording(undo, f.record('new SH'), f.save)).rejects.toThrow('capture failed'); + expect(f.events).toEqual([]); }); - it('does not remark no-op recordings or a reset history', async () => { + it('leaves no-op recordings clean and does not add a second saved mark', async () => { const f = fixture(); await finishSavedLightFXRecording(f.undo, f.record('old SH'), f.save); expect(f.undo.markSaved).not.toHaveBeenCalled(); - await finishSavedLightFXRecording(f.undo, f.record('new SH'), f.save, async () => { f.manager.reset(); }); - expect(f.undo.markSaved).not.toHaveBeenCalled(); + expect(f.read()).toEqual({ data: 'old SH', disk: 'old SH', dirty: false }); }); }); diff --git a/src/core/scene/test/lightmap-result-recording.test.ts b/src/core/scene/test/lightmap-result-recording.test.ts index 548ea78db..bd295ce94 100644 --- a/src/core/scene/test/lightmap-result-recording.test.ts +++ b/src/core/scene/test/lightmap-result-recording.test.ts @@ -59,7 +59,7 @@ describe('Lightmap result recording targets', () => { expect(mockUndo.endRecording).toHaveBeenCalledWith('recording'); expect(mockCommit).toHaveBeenCalledWith('operation'); expect(mockSave).toHaveBeenCalledTimes(saveScene ? 1 : 0); - expect(mockUndo.markSaved).toHaveBeenCalledTimes(saveScene ? 1 : 0); + expect(mockUndo.markSaved).not.toHaveBeenCalled(); }); it.each([false, true])('deduplicates multiple Terrain blocks and records all cleared bindings (save=%s)', async saveScene => { const f = fixture(); @@ -70,16 +70,16 @@ describe('Lightmap result recording targets', () => { expect(f.terrain._updateLightmap).toHaveBeenCalledWith(1, null, 0, 0, 0, 0); expect(mockUndo.endRecording).toHaveBeenCalledWith('recording'); expect(mockSave).toHaveBeenCalledTimes(saveScene ? 1 : 0); - expect(mockUndo.markSaved).toHaveBeenCalledTimes(saveScene ? 1 : 0); + expect(mockUndo.markSaved).not.toHaveBeenCalled(); }); - it('restores bindings and cancels the recording if saving fails', async () => { + it('retains cleared bindings and history if saving fails', async () => { const f = fixture(); mockSave.mockRejectedValueOnce(new Error('disk unavailable')); await expect(f.service.clearBake()).rejects.toThrow('disk unavailable'); - expect(mockUndo.cancelRecording).toHaveBeenCalledWith('recording'); - expect(mockUndo.endRecording).not.toHaveBeenCalled(); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(mockUndo.endRecording).toHaveBeenCalledWith('recording'); expect(mockUndo.markSaved).not.toHaveBeenCalled(); - expect(f.model._updateLightmap).toHaveBeenLastCalledWith(f.oldTexture, 1, 2, 3, 4); - expect(f.terrain._updateLightmap).toHaveBeenLastCalledWith(1, f.oldTexture, 5, 6, 7, 8); + expect(f.model._updateLightmap).toHaveBeenLastCalledWith(null, 0, 0, 0, 0); + expect(f.terrain._updateLightmap).toHaveBeenLastCalledWith(1, null, 0, 0, 0, 0); }); }); From db2f6aa1a05527aefd538b32a4a5a1ac3e7d1c08 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 18:20:57 +0800 Subject: [PATCH 30/64] =?UTF-8?q?fix(scene):=20=E4=BF=9D=E7=95=99=E5=9C=B0?= =?UTF-8?q?=E5=BD=A2=E4=BF=9D=E5=AD=98=E5=BC=82=E5=B8=B8=E4=B8=8E=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=A4=B1=E8=B4=A5=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 1 + .../scene/scene-process/service/editor.ts | 20 +++++------- .../scene/scene-process/service/terrain.ts | 6 ++-- .../service-core/message-callsite.test.ts | 28 ++++++++++++++++ src/core/scene/test/terrain-service.test.ts | 32 +++++++++++++++++++ 5 files changed, 72 insertions(+), 15 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index cb0a18c17..8833db639 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -155,6 +155,7 @@ Bake 按「确认原生产物提交 → 应用场景结果 → 完成 Undo 录 - 结果应用或录制前失败:恢复旧内存,不保存。已确认提交的产物仍保留,避免把不可逆的资产提交误当成可回滚事务。 - Undo 已入栈后的保存失败/回应丢失:抛出包含 `LightFX result retained` 和原始原因的错误,**保留当前结果、Undo 和产物**。保存请求可能未写盘,也可能已写盘但没有返回确认;不能通过自动恢复旧内存或删除贴图来猜测磁盘状态。调用方应刷新实际结果,允许用户检查后重新保存或 Undo,不要把失败解释为“场景未改变”。 - 失败时不会额外标记已保存。保存尚未写盘时结果保持 dirty;若保存已确认完成后才发生外层回应错误,内存与已保存结果相同,可以保持 clean。dirty 不是保存失败原因或磁盘写入状态的唯一证据。 +- 场景保存先等待 Terrain 资产保存。已注册 Terrain 服务抛错或批量结果报告失败时,不继续保存 `.scene`、不广播保存成功、不更新保存点;后一个 Terrain 成功也不能覆盖前一个失败。已经成功写入的 Terrain 文件不做猜测性回滚,失败项保留 dirty 供重试。 这保证正常运行实例中不先发布可被原生回滚删除的场景引用,但不是磁盘/Terrain/资产的多文件原子事务。进程崩溃恢复、任意并发编辑与保存协调、未引用版本安全回收仍需独立协议,不由此提交顺序承诺。 diff --git a/src/core/scene/scene-process/service/editor.ts b/src/core/scene/scene-process/service/editor.ts index e7b57bf3a..0779d3d8e 100644 --- a/src/core/scene/scene-process/service/editor.ts +++ b/src/core/scene/scene-process/service/editor.ts @@ -1,5 +1,5 @@ import cc from 'cc'; -import { BaseService, register, Service } from './core'; +import { BaseService, register, Service, queryRegisteredService } from './core'; import { InternalServiceEvents } from './core/internal-events'; import { IBaseIdentifier, @@ -18,6 +18,7 @@ import { IAssetInfo } from '../../../assets/@types/public'; import { Rpc } from '../rpc'; import { enrichMissingDependencyError } from './error-utils'; import type { IEditorSessionService, IEditorSessionSnapshot } from './core/editor-session'; +import type { ITerrainService } from '../../common/terrain'; /** * EditorAsset - 统一的编辑器管理入口 @@ -308,17 +309,12 @@ export class EditorService extends BaseService implements IEditor /** Terrain data lives in .terrain assets, not in the scene JSON. */ private async saveTerrainAssets(): Promise { - try { - const terrain = (Service as any).Terrain; - if (!terrain?.saveAsset) return; - const result = await terrain.saveAsset(false); - if (result === 2) { - throw new Error('Terrain asset save failed or requires a Save As target.'); - } - } catch (error) { - // During early bootstrap or isolated editor tests TerrainService may - // not be registered. Real terrain save failures use the explicit error above. - if (error instanceof Error && error.message.includes('requires a Save As')) throw error; + // Missing registration during bootstrap is distinct from a registered service failing. + const terrain = queryRegisteredService('Terrain'); + if (!terrain) return; + const result = await terrain.saveAsset(false); + if (result === 2) { + throw new Error('Terrain asset save failed or requires a Save As target.'); } } diff --git a/src/core/scene/scene-process/service/terrain.ts b/src/core/scene/scene-process/service/terrain.ts index 9ec5d6685..f5df8019a 100644 --- a/src/core/scene/scene-process/service/terrain.ts +++ b/src/core/scene/scene-process/service/terrain.ts @@ -685,7 +685,7 @@ export class TerrainService extends BaseService implements ITerr continue; } this.setDirty(terrain, false); - result = 0; + if (result !== 2) result = 0; } catch (error) { console.error('[Terrain] saveAsset failed:', error); result = 2; @@ -702,7 +702,7 @@ export class TerrainService extends BaseService implements ITerr if (uuid) { const code = await this.saveAsset(isClose, terrain); if (code === 2) result = 2; - else if (code === 0) result = 0; + else if (code === 0 && result !== 2) result = 0; continue; } @@ -721,7 +721,7 @@ export class TerrainService extends BaseService implements ITerr if (created) { (terrain as any)._asset = await loadAny(created.uuid ?? created); this.setDirty(terrain, false); - result = 0; + if (result !== 2) result = 0; } else { result = 2; } diff --git a/src/core/scene/test/service-core/message-callsite.test.ts b/src/core/scene/test/service-core/message-callsite.test.ts index 2315040ee..b9180e10e 100644 --- a/src/core/scene/test/service-core/message-callsite.test.ts +++ b/src/core/scene/test/service-core/message-callsite.test.ts @@ -560,6 +560,34 @@ describe('ServiceEvents 事件发射集成测试', () => { expect(listener).toHaveBeenCalledTimes(1); }); + + it.each(['reported', 'thrown'])('does not write the scene or mark it saved after a %s Terrain failure', async failure => { + const { SceneEditor } = require('../../scene-process/service/editors'); + const core = require('../../scene-process/service/core/decorator'); + const terrain = { saveAsset: jest.fn(async () => { + if (failure === 'thrown') throw new Error('Terrain storage failed'); + return 2; + }) }; + const query = jest.spyOn(core, 'queryRegisteredService').mockReturnValue(terrain); + const markSaved = jest.spyOn(editorService, '_markUndoSaved'); + const listener = jest.fn(); + globalEventEmitter.on('editor:save', listener); + const uuid = 'terrain-save-failed-scene'; + const editor = Object.assign(Object.create(SceneEditor.prototype), { save: jest.fn() }); + editorService.editorMap.set(uuid, editor); + editorService.currentEditorUuid = uuid; + mockRpcRequest.mockResolvedValueOnce({ uuid, url: 'test.scene', type: 'scene' }); + try { + await expect(editorService.save({})).rejects.toThrow(failure === 'thrown' ? 'Terrain storage failed' : 'Terrain asset save failed'); + expect(terrain.saveAsset).toHaveBeenCalledWith(false); + expect(editor.save).not.toHaveBeenCalled(); + expect(markSaved).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + } finally { + query.mockRestore(); + markSaved.mockRestore(); + } + }); }); // ── NodeService: setProperty(name) → ServiceEvents ── diff --git a/src/core/scene/test/terrain-service.test.ts b/src/core/scene/test/terrain-service.test.ts index d9c758009..da3df165a 100644 --- a/src/core/scene/test/terrain-service.test.ts +++ b/src/core/scene/test/terrain-service.test.ts @@ -738,6 +738,38 @@ describe('TerrainService target-safe public capability', () => { consoleError.mockRestore(); }); + it.each(['saveAsset', 'saveAssetDialog'] as const)('%s preserves an earlier failure when a later terrain saves', async method => { + const failed = createFixture('failed', 'failed-terrain'); + const saved = createFixture('saved', 'saved-terrain'); + for (const f of [failed, saved]) { + (f.terrain as any)._asset = { _uuid: f.target.componentUuid }; + (f.terrain as any).isTerrainChange = true; + } + mockAssetBinarySave.mockResolvedValueOnce(null).mockResolvedValueOnce({ uuid: saved.target.componentUuid }); + const service = new TerrainService(); + service.editedComponents.push(failed.terrain, saved.terrain); + jest.spyOn(service, 'serialize').mockReturnValue(new Uint8Array([1])); + await expect(service[method]()).resolves.toBe(2); + expect({ failedDirty: (failed.terrain as any).isTerrainChange, savedDirty: (saved.terrain as any).isTerrainChange }).toEqual({ failedDirty: true, savedDirty: false }); + }); + + it('does not hide a failed existing terrain when a later unsaved terrain is created', async () => { + const failed = createFixture('failed', 'failed-terrain'); + const created = createFixture('created', 'created-terrain'); + (failed.terrain as any)._asset = { _uuid: 'failed' }; + (created.terrain as any)._asset = null; + (failed.terrain as any).isTerrainChange = true; + (created.terrain as any).isTerrainChange = true; + mockAssetBinarySave.mockResolvedValue(null); + mockAssetBinaryCreate.mockResolvedValue({ uuid: 'created' }); + mockLoadAny.mockResolvedValue({ _uuid: 'created' }); + const service = new TerrainService(); + service.editedComponents.push(failed.terrain, created.terrain); + jest.spyOn(service, 'serialize').mockReturnValue(new Uint8Array([1])); + await expect(service.saveAssetDialog('db://assets/created.terrain')).resolves.toBe(2); + expect({ failedDirty: (failed.terrain as any).isTerrainChange, createdDirty: (created.terrain as any).isTerrainChange }).toEqual({ failedDirty: true, createdDirty: false }); + }); + it('creates a Terrain asset through the binary client using the requested db:// target', async () => { const fixture = createFixture(); (fixture.terrain as any)._asset = null; From 0d091e32823610979a89641b754bd77112e5ca94 Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Thu, 10 Sep 2026 18:34:33 +0800 Subject: [PATCH 31/64] test(types): update DTS snapshot for lighting APIs --- .../__snapshots__/dts-snapshot.test.ts.snap | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) 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 d54911a62..fb65f1c2a 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 @@ -6692,6 +6692,17 @@ export declare interface IGizmoService { showSelectionRegion(left: number, right: number, top: number, bottom: number): void; hideSelectionRegion(): void; execGizmoMethods(name: string, funcName: string, params?: any[]): any; + toggleLightProbeEditMode(enabled: boolean): boolean; + queryLightProbeEditMode(): boolean; + toggleLightProbeBoundingBoxEditMode(enabled: boolean): boolean; + queryLightProbeBoundingBoxEditMode(): boolean; + selectAllLightProbes(): void; + unselectAllLightProbes(): void; + queryLightProbeSelectedCount(): number; + duplicateSelectedLightProbes(): Promise; + deleteSelectedLightProbes(): Promise; + generateLightProbes(): number; + regionSelectLightProbes(left: number, right: number, top: number, bottom: number, additive: boolean): number; } export declare interface IInsertLODOptions { path: string; @@ -6706,7 +6717,24 @@ export declare interface ILightFXCancelResult { cancelled: boolean; target: 'light-probe' | 'lightmap' | null; } +export declare interface ILightFXDiagnostics { + version: 1; + stage: string; + logs: string[]; + progress?: string; +} +export declare interface ILightmapBakeCapabilities { + diagnostics?: ILightFXDiagnostics; + version: 1; + resultLifecycleVersion: 1; + sceneTransactionVersion: 1; + assetVersion: 1; + cancelVersion?: 1; + cancellable?: boolean; + busy: boolean; +} export declare interface ILightmapBakeInfo { + readiness?: ILightmapReadiness; sceneUrl: string; baked: boolean; meshCount: number; @@ -6733,6 +6761,7 @@ export declare interface ILightmapBakeOptions { timeoutMs?: number; } export declare interface ILightmapBakeResult { + diagnostics?: ILightFXDiagnostics; sceneUrl: string; textureUrls: string[]; meshCount: number; @@ -6740,6 +6769,7 @@ export declare interface ILightmapBakeResult { durationMs: number; } export declare interface ILightmapBakeService extends IServiceEvents { + queryCapabilities(): Promise; bake(options: ILightmapBakeOptions): Promise; queryBakeInfo(): Promise; clearBake(options?: { @@ -6750,6 +6780,18 @@ export declare interface ILightmapBakeService extends IServiceEvents { }>; cancel(): Promise; } +export declare interface ILightmapReadiness { + version: 1; + objects: { + componentUuid: string; + nodeName: string; + kind: 'mesh' | 'terrain'; + receivesLightmap: boolean; + castsShadow: boolean; + lightmapSize: number; + issues: LightmapObjectIssue[]; + }[]; +} export declare interface ILightmapTextureInfo { uuid: string; url: string; @@ -6758,6 +6800,15 @@ export declare interface ILightmapTextureInfo { createdAt: number; modifiedAt: number; } +export declare interface ILightProbeBakeCapabilities { + diagnostics?: ILightFXDiagnostics; + version: 1; + cancelVersion?: 1; + cancellable?: boolean; + resultLifecycleVersion: 1; + sceneTransactionVersion: 1; + busy: boolean; +} export declare interface ILightProbeBakeOptions { giScale?: number; giSamples?: number; @@ -6770,6 +6821,7 @@ export declare interface ILightProbeBakeOptions { timeoutMs?: number; } export declare interface ILightProbeBakeResult { + diagnostics?: ILightFXDiagnostics; sceneUrl: string; probeCount: number; giScale: number; @@ -6782,6 +6834,7 @@ export declare interface ILightProbeBakeResult { durationMs: number; } export declare interface ILightProbeBakeService extends IServiceEvents { + queryCapabilities(): Promise; bake(options: ILightProbeBakeOptions): Promise; clearBake(options?: { saveScene?: boolean; @@ -7677,6 +7730,7 @@ export declare interface LabelAtlasAssetUserData { spriteFrameUuid: string; _fntConfig: FntData; } +export declare type LightmapObjectIssue = 'inactive' | 'movable' | 'editor-only' | 'disabled' | 'not-participating' | 'missing-mesh' | 'invalid-uv1' | 'skinned-static-pose' | 'material-approximation' | 'terrain-translation-only'; export declare interface LODsOption { screenRatio: number; faceCount: number; From 2d3c49acbf776ba3c6e8f486eb1adfd1856efaa8 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 19:52:35 +0800 Subject: [PATCH 32/64] =?UTF-8?q?fix(scene):=20=E5=AF=B9=E9=BD=90=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E7=BB=84=E5=B9=B3=E7=A7=BB=E5=90=8E=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E7=83=98=E7=84=99=E7=B3=BB=E6=95=B0=E7=9A=84=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 2 +- .../scene/scene-process/service/node/index.ts | 2 +- .../service/scene/light-probe-transform.ts | 8 +++++--- .../scene/test/light-probe-transform.test.ts | 18 +++++++++++++++++- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 8833db639..524723eb5 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -95,7 +95,7 @@ Scene runtime 先通过内部 `reserveSceneOperation` 取得宿主生成的事 所有参数均可选,未传入时使用场景当前值。`giScale`、`giSamples` 和 `bounces` 参与 LightFX 计算;`reduceRinging`、`showWireframe`、`showConvex` 和 `lightProbeSphereVolume` 用于烘焙结果后处理或编辑器显示。烘焙成功后,本次的有效参数与 SH 结果作为同一次 Undo 操作写回 `LightProbeInfo`;计算失败、提交未确认或取消胜出时不应用结果。结果已录制后的保存失败保留新结果,详见下文。 -编辑已启用探针组或其父节点的位置时,CLI 会同步全局采样点和四面体。只有实际采样位置改变才清空旧 SH,避免把旧位置的烘焙结果用于新位置;不会重新生成组件内手工编辑过的采样点。普通节点属性操作和 Gizmo recording 会把受影响的 Scene 数据纳入同一次撤销记录:Undo 恢复旧位置与旧 SH,Redo 恢复新位置与失效状态。保存仍由调用方决定,移动后需要重新烘焙。 +平移已启用探针组或其父节点时,CLI 同步全局采样点和四面体,并保留所有组的原 SH 系数,刷新光照缓存;对齐 Creator 3.8.8 移动 A 组后 A/B 组系数都保留的行为。不重新生成组件内手工编辑过的采样点,也不自动重新烘焙。普通节点属性操作和 Gizmo recording 仍记录位置编辑的 Undo/Redo,保存仍由调用方决定;保留系数不代表已按新位置重烘焙。 此同步沿用当前引擎的 `localProbe + worldPosition` 约定,探针球、范围盒与框选投影也采用相同约定,不额外给局部采样点乘旋转/缩放。祖先旋转/缩放若改变子组世界位置,采样位置同步并使旧 SH 失效;改父级将受影响 Scene 的结果快照放在节点恢复之后,Scene 自身不参与重挂。组件 Undo 替换 probes 数组后重新同步引擎注册引用,避免后续变换再次使用旧数组。 diff --git a/src/core/scene/scene-process/service/node/index.ts b/src/core/scene/scene-process/service/node/index.ts index 8222eeac3..675cc89cd 100644 --- a/src/core/scene/scene-process/service/node/index.ts +++ b/src/core/scene/scene-process/service/node/index.ts @@ -220,7 +220,7 @@ export class NodeManager { } onNodeTransformChanged(node: Node, transformBit: any) { - synchronizeLightProbeTransform(node); + synchronizeLightProbeTransform(node, transformBit === Node.TransformBit.POSITION); const changeOpts: IChangeNodeOptions = { type: NodeEventType.TRANSFORM_CHANGED, source: EventSourceType.ENGINE }; switch (transformBit) { diff --git a/src/core/scene/scene-process/service/scene/light-probe-transform.ts b/src/core/scene/scene-process/service/scene/light-probe-transform.ts index bb2176929..023a1a76f 100644 --- a/src/core/scene/scene-process/service/scene/light-probe-transform.ts +++ b/src/core/scene/scene-process/service/scene/light-probe-transform.ts @@ -9,7 +9,7 @@ export function getLightProbeTransformScene(node: Node): Scene | undefined { } /** Keeps the engine's world-position probe convention current after a node transform. */ -export function synchronizeLightProbeTransform(node: Node): void { +export function synchronizeLightProbeTransform(node: Node, preserveCoefficients = false): void { const scene = getLightProbeTransformScene(node); if (!scene) return; const info = scene.globals.lightProbeInfo; @@ -20,8 +20,10 @@ export function synchronizeLightProbeTransform(node: Node): void { const after = info.data?.probes ?? []; if (before.length === after.length && before.every((point, index) => Vec3.strictEquals(point, after[index].position))) return; info.update(true); - // A changed sample position cannot retain SH baked at the previous location. - info.onProbeBakeCleared(); + // Creator retains every group's baked coefficients when a group/ancestor is translated. + // Other edit paths keep their existing invalidation policy until separately verified. + if (preserveCoefficients) info.onProbeBakeFinished(); + else info.onProbeBakeCleared(); } /** Adds affected scene snapshots last so Undo restores node poses before probe data. */ diff --git a/src/core/scene/test/light-probe-transform.test.ts b/src/core/scene/test/light-probe-transform.test.ts index dc010716b..f35e526ea 100644 --- a/src/core/scene/test/light-probe-transform.test.ts +++ b/src/core/scene/test/light-probe-transform.test.ts @@ -24,6 +24,7 @@ function fixture() { probes.forEach((probe, i) => Object.assign(probe.position, nextPositions[i])); }), onProbeBakeCleared: jest.fn(() => { events.push('clear'); probes.forEach(probe => { probe.coefficients = []; }); }), + onProbeBakeFinished: jest.fn(() => { events.push('refresh'); }), }; const scene = { isValid: true, globals: { lightProbeInfo: info }, getComponentsInChildren: () => [group] } as unknown as Scene; Object.defineProperty(scene, 'scene', { value: scene }); @@ -32,6 +33,21 @@ function fixture() { } describe('Light probe position synchronization', () => { + it('retains moved and stationary groups coefficients when translating a group', () => { + const { node, nextPositions, info, events } = fixture(); + // First two samples belong to A, last two to the stationary group B. + info.data.probes.forEach((probe, index) => { probe.coefficients = [new Vec3(index + 1, 2, 3)]; }); + const coefficients = info.data.probes.map(probe => probe.coefficients.map(value => Vec3.clone(value))); + nextPositions.slice(0, 2).forEach(point => { point.x += 7; }); + synchronizeLightProbeTransform(node, true); + expect({ events, positions: info.data.probes.map(probe => probe.position), coefficients: info.data.probes.map(probe => probe.coefficients) }) + .toEqual({ events: ['positions', 'tetrahedrons', 'refresh'], positions: nextPositions, coefficients }); + synchronizeLightProbeTransform(node, true); + expect(info.onProbeBakeCleared).not.toHaveBeenCalled(); + expect(info.onProbeBakeFinished).toHaveBeenCalledTimes(1); + expect(info.update.mock.calls).toEqual([[false], [true], [false]]); + }); + it('updates positions, rebuilds once and invalidates SH only when samples actually moved', () => { const { node, nextPositions, info, events } = fixture(); nextPositions.forEach(point => { point.x += 7; }); @@ -77,6 +93,6 @@ describe('Light probe position synchronization', () => { it('ignores detached or invalid nodes without a scene', () => { const nodes = [{ isValid: true }, { isValid: false }] as Node[]; expect(withLightProbeTransformScenes(nodes)).toEqual(nodes); - nodes.forEach(synchronizeLightProbeTransform); + nodes.forEach(node => synchronizeLightProbeTransform(node)); }); }); From 531413bdbb8eb7012d5d55c47ab66a94390604e6 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 19:57:53 +0800 Subject: [PATCH 33/64] =?UTF-8?q?refactor(lightmap):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E9=A2=9D=E5=A4=96=E5=AF=B9=E8=B1=A1=E6=89=AB=E6=8F=8F=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=AF=BC=E5=87=BA=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 6 +- src/core/scene/common/lightfx-bake.ts | 18 ------ .../service/baking/lightfx/exporter.ts | 2 +- .../service/baking/lightfx/lightmap-uv.ts | 8 +++ .../service/baking/lightfx/readiness.ts | 59 ------------------- .../scene-process/service/lightmap-bake.ts | 2 - .../scene/test/lightmap-bake-info.test.ts | 6 +- .../scene/test/lightmap-readiness.test.ts | 42 ------------- src/core/scene/test/lightmap-uv.test.ts | 10 ++++ 9 files changed, 22 insertions(+), 131 deletions(-) create mode 100644 src/core/scene/scene-process/service/baking/lightfx/lightmap-uv.ts delete mode 100644 src/core/scene/scene-process/service/baking/lightfx/readiness.ts delete mode 100644 src/core/scene/test/lightmap-readiness.test.ts create mode 100644 src/core/scene/test/lightmap-uv.test.ts diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 524723eb5..a3ffc7255 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -262,11 +262,9 @@ Bake 按「确认原生产物提交 → 应用场景结果 → 完成 Undo 录 Pink 应在场景打开、烘焙完成和清理完成后调用该工具刷新面板。缩略图加载、RGBA 通道切换和时间格式化由 Pink 根据资源 URL/UUID 实现,CLI 不传输图片像素。 -#### 下次烘焙对象检查 +#### 导出输入校验 -`queryBakeInfo()` 另返回可选 `readiness: { version: 1, objects }`,与既有绑定结果独立。每项包含组件 UUID、节点名称、mesh/terrain 类型、是否接收贴图/投射阴影、贴图大小以及 `issues`。查询只读,跳过 DontSave 编辑器辅助子树,报告 inactive/Movable 祖先、禁用组件、未参与、缺 mesh、无效 UV1 等条件。 - -接收贴图的 Mesh 在查询及实际导出时检查 UV1 长度是否等于顶点数的两倍、所有值是否有限;检查不包含 UV 重叠或自动展开。蒙皮输出静态网格顶点,不代表当前动画姿态;材质只导出支持的属性;Terrain 基于高度场和世界位置,不额外导出旋转/缩放。调用方可复用 Inspector 的 Bake Settings 修改参与配置,不应把警告、参与标记或查询成功当作画质保证。 +`queryBakeInfo()` 只查询已烘焙的绑定与贴图信息,不提供额外的下次烘焙对象诊断。接收贴图的 Mesh 在实际导出时仍检查 UV1 长度是否等于顶点数的两倍、所有值是否有限;检查不包含 UV 重叠或自动展开。参与配置由 Inspector 的 Bake Settings 编辑;查询成功不代表输入有效或保证最终画质。 ### 清理 Lightmap diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts index 53078b635..f3e73d7de 100644 --- a/src/core/scene/common/lightfx-bake.ts +++ b/src/core/scene/common/lightfx-bake.ts @@ -86,8 +86,6 @@ export interface ILightmapBakeResult { } export interface ILightmapBakeInfo { - /** Read-only next-bake diagnostics; absent on older runtimes. Does not guarantee image quality. */ - readiness?: ILightmapReadiness; sceneUrl: string; baked: boolean; meshCount: number; @@ -98,22 +96,6 @@ export interface ILightmapBakeInfo { missingTextureUuids: string[]; } -export type LightmapObjectIssue = 'inactive' | 'movable' | 'editor-only' | 'disabled' | 'not-participating' - | 'missing-mesh' | 'invalid-uv1' | 'skinned-static-pose' | 'material-approximation' | 'terrain-translation-only'; - -export interface ILightmapReadiness { - version: 1; - objects: { - componentUuid: string; - nodeName: string; - kind: 'mesh' | 'terrain'; - receivesLightmap: boolean; - castsShadow: boolean; - lightmapSize: number; - issues: LightmapObjectIssue[]; - }[]; -} - export interface ILightFXCancelResult { cancelled: boolean; target: 'light-probe' | 'lightmap' | null; diff --git a/src/core/scene/scene-process/service/baking/lightfx/exporter.ts b/src/core/scene/scene-process/service/baking/lightfx/exporter.ts index c215ba786..947de7bf2 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/exporter.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/exporter.ts @@ -2,7 +2,7 @@ import { DirectionalLight, director, gfx, Light, MeshRenderer, MobilityMode, ren import type { ILightFXTextureSource } from '../../../../common/lightfx-host'; import { lightFXBakeHost } from './host'; import { LightFXBakeTarget, LightFXLight, LightFXMaterial, LightFXMesh, LightFXSettings, LightFXTerrain, LightFXWorld } from './types'; -import { validLightmapUV } from './readiness'; +import { validLightmapUV } from './lightmap-uv'; export interface LightFXExport { world: LightFXWorld; diff --git a/src/core/scene/scene-process/service/baking/lightfx/lightmap-uv.ts b/src/core/scene/scene-process/service/baking/lightfx/lightmap-uv.ts new file mode 100644 index 000000000..f5f92123a --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/lightmap-uv.ts @@ -0,0 +1,8 @@ +/** UV presence alone is insufficient: truncated or non-finite attributes cannot be exported safely. */ +export function validLightmapUV(uv: ArrayLike | null, vertexCount: number): boolean { + if (!uv || !Number.isInteger(vertexCount) || vertexCount <= 0 || uv.length !== vertexCount * 2) { return false; } + for (let index = 0; index < uv.length; index++) { + if (!Number.isFinite(uv[index])) { return false; } + } + return true; +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/readiness.ts b/src/core/scene/scene-process/service/baking/lightfx/readiness.ts deleted file mode 100644 index bea8a25df..000000000 --- a/src/core/scene/scene-process/service/baking/lightfx/readiness.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { gfx, MeshRenderer, MobilityMode, Scene, SkinnedMeshRenderer, Terrain, type Node } from 'cc'; -import type { ILightmapReadiness, LightmapObjectIssue } from '../../../../common/lightfx-bake'; - -/** UV presence alone is insufficient: truncated or non-finite attributes cannot be exported safely. */ -export function validLightmapUV(uv: ArrayLike | null, vertexCount: number): boolean { - if (!uv || !Number.isInteger(vertexCount) || vertexCount <= 0 || uv.length !== vertexCount * 2) { return false; } - for (let index = 0; index < uv.length; index++) { - if (!Number.isFinite(uv[index])) { return false; } - } - return true; -} - -/** Mirrors exporter participation without exporting geometry, resolving textures, or mutating a scene. */ -export function queryLightmapReadiness(scene: Scene): ILightmapReadiness { - const objects: ILightmapReadiness['objects'] = []; - const visit = (node: Node, inherited: LightmapObjectIssue[]) => { - // Gizmo/controllers live below the scene but are not user bake candidates. - if (node !== scene && (node._objFlags & (1 << 10))) { return; } - const excluded = [...inherited]; - if (node !== scene) { - if (!node.activeInHierarchy) { excluded.push('inactive'); } - if (node.mobility === MobilityMode.Movable) { excluded.push('movable'); } - for (const model of node.getComponents(MeshRenderer)) { - const issues = [...new Set(excluded)]; - if (!model.enabled) { issues.push('disabled'); } - const settings = model.bakeSettings; - if (!settings.bakeable && !settings.castShadow) { issues.push('not-participating'); } - if (!model.mesh) { issues.push('missing-mesh'); } - const participates = !issues.length; - const receivesLightmap = participates && settings.bakeable && settings.lightmapSize > 0; - if (receivesLightmap && model.mesh) { - for (let primitive = 0; primitive < model.mesh.struct.primitives.length; primitive++) { - const positions = model.mesh.readAttribute(primitive, gfx.AttributeName.ATTR_POSITION); - const uv = model.mesh.readAttribute(primitive, gfx.AttributeName.ATTR_TEX_COORD1); - if (!validLightmapUV(uv, (positions?.length ?? 0) / 3)) { issues.push('invalid-uv1'); break; } - } - } - if (participates) { - if (model instanceof SkinnedMeshRenderer) { issues.push('skinned-static-pose'); } - issues.push('material-approximation'); - } - objects.push({ componentUuid: model.uuid, nodeName: node.name, kind: 'mesh', receivesLightmap, - castsShadow: participates && settings.castShadow, lightmapSize: settings.lightmapSize, issues }); - } - for (const terrain of node.getComponents(Terrain)) { - const issues = [...new Set(excluded)]; - if (!terrain.enabled) { issues.push('disabled'); } - const participates = !issues.length; - if (participates) { issues.push('terrain-translation-only'); } - objects.push({ componentUuid: terrain.uuid, nodeName: node.name, kind: 'terrain', - receivesLightmap: participates && terrain.lightMapSize > 0, castsShadow: participates, - lightmapSize: terrain.lightMapSize, issues }); - } - } - for (const child of node.children) { visit(child, excluded); } - }; - visit(scene, []); - return { version: 1, objects }; -} diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index eacce5d55..9a3aa08f0 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -12,7 +12,6 @@ import { lightFXSceneOperation } from './baking/lightfx/scene-operation'; import { finishSavedLightFXRecording, LightFXResultRetainedError } from './baking/lightfx/saved-recording'; import { BaseService, register, Service } from './core'; import { loadPreviewAsset } from './preview/asset-reload'; -import { queryLightmapReadiness } from './baking/lightfx/readiness'; interface LightmapBinding { target: any; @@ -149,7 +148,6 @@ export class LightmapBakeService extends BaseService impleme }); return { sceneUrl: await this.querySceneUrl(), - readiness: queryLightmapReadiness(scene), baked: meshCount > 0 || terrainCount > 0, meshCount, terrainCount, diff --git a/src/core/scene/test/lightmap-bake-info.test.ts b/src/core/scene/test/lightmap-bake-info.test.ts index 73554e0a4..4a408fecb 100644 --- a/src/core/scene/test/lightmap-bake-info.test.ts +++ b/src/core/scene/test/lightmap-bake-info.test.ts @@ -2,9 +2,6 @@ const mockGetScene = jest.fn(); const mockQueryLightmapTextureInfo = jest.fn(); const mockMeshRenderer = class MeshRenderer {}; const mockTerrain = class Terrain {}; -jest.mock('../scene-process/service/baking/lightfx/readiness', () => ({ - queryLightmapReadiness: () => ({ version: 1, objects: [] }), -})); jest.mock('cc', () => ({ director: { getScene: mockGetScene }, @@ -51,7 +48,7 @@ describe('LightmapBakeService bake information', () => { const scene = { ...node([], [], [ node([ - { bakeSettings: { texture: meshTexture } }, + { bakeSettings: { texture: meshTexture }, get mesh() { throw new Error('Result queries must not scan bake inputs'); } }, { bakeSettings: { texture: meshTexture } }, ]), node([], [{ @@ -84,7 +81,6 @@ describe('LightmapBakeService bake information', () => { await expect(service.queryBakeInfo()).resolves.toEqual({ sceneUrl: 'db://assets/Lightmap.scene', - readiness: { version: 1, objects: [] }, baked: true, meshCount: 2, terrainCount: 1, diff --git a/src/core/scene/test/lightmap-readiness.test.ts b/src/core/scene/test/lightmap-readiness.test.ts deleted file mode 100644 index 9f0667e24..000000000 --- a/src/core/scene/test/lightmap-readiness.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -jest.mock('cc', () => ({ - gfx: { AttributeName: { ATTR_POSITION: 'position', ATTR_TEX_COORD1: 'uv1' } }, - MeshRenderer: class {}, SkinnedMeshRenderer: class {}, Terrain: class {}, Scene: class {}, - MobilityMode: { Movable: 2 }, -})); -import { MeshRenderer, Terrain, type Scene } from 'cc'; -import { queryLightmapReadiness, validLightmapUV } from '../scene-process/service/baking/lightfx/readiness'; - -describe('Lightmap readiness', () => { - it.each([ - [null, 3, false], [[0, 0], 3, false], [[0, NaN], 1, false], [[Infinity, 0], 1, false], - [[0, 0, 1, 0, 0, 1], 3, true], [new Float32Array([0, 1]), 1, true], [[], 0, false], - ])('validates UV1 %p for %p vertices', (uv, count, expected) => { - expect(validLightmapUV(uv as number[] | null, count as number)).toBe(expected); - }); - - it('reports inherited exclusions, receivers, shadow-only models and invalid UV without mutation', () => { - const renderer = (uuid: string, bakeable = true, uv: number[] | null = [0, 0, 1, 0, 0, 1]) => ({ - uuid, enabled: true, bakeSettings: { bakeable, castShadow: true, lightmapSize: 64 }, - mesh: { struct: { primitives: [{}] }, readAttribute: (_index: number, name: string) => name === 'uv1' ? uv : Array(9).fill(0) }, - }); - const object = (name: string, models: ReturnType[], children: unknown[] = [], mobility = 0) => ({ - name, activeInHierarchy: true, mobility, _objFlags: 0, children, - getComponents: (type: unknown) => type === MeshRenderer ? models : type === Terrain ? [] : [], - }); - const scene = object('scene', [], [ - object('valid', [renderer('a')]), object('shadow', [renderer('b', false, null)]), - object('invalid', [renderer('c', true, null)]), - object('parent', [], [object('child', [renderer('d')])], 2), - { ...object('editor helper', [renderer('internal')], [object('nested helper', [renderer('nested')])]), _objFlags: 1 << 10 }, - ]) as unknown as Scene; - const before = JSON.stringify(scene); - const result = queryLightmapReadiness(scene); - expect(result.objects.map(({ componentUuid, receivesLightmap, castsShadow, issues }) => ({ componentUuid, receivesLightmap, castsShadow, issues }))).toEqual([ - { componentUuid: 'a', receivesLightmap: true, castsShadow: true, issues: ['material-approximation'] }, - { componentUuid: 'b', receivesLightmap: false, castsShadow: true, issues: ['material-approximation'] }, - { componentUuid: 'c', receivesLightmap: true, castsShadow: true, issues: ['invalid-uv1', 'material-approximation'] }, - { componentUuid: 'd', receivesLightmap: false, castsShadow: false, issues: ['movable'] }, - ]); - expect(JSON.stringify(scene)).toBe(before); - }); -}); diff --git a/src/core/scene/test/lightmap-uv.test.ts b/src/core/scene/test/lightmap-uv.test.ts new file mode 100644 index 000000000..c9d39ff39 --- /dev/null +++ b/src/core/scene/test/lightmap-uv.test.ts @@ -0,0 +1,10 @@ +import { validLightmapUV } from '../scene-process/service/baking/lightfx/lightmap-uv'; + +describe('Lightmap export UV validation', () => { + it.each([ + [null, 3, false], [[0, 0], 3, false], [[0, NaN], 1, false], [[Infinity, 0], 1, false], + [[0, 0, 1, 0, 0, 1], 3, true], [new Float32Array([0, 1]), 1, true], [[], 0, false], + ])('validates UV1 %p for %p vertices', (uv, count, expected) => { + expect(validLightmapUV(uv as number[] | null, count as number)).toBe(expected); + }); +}); From 14e94bc0fe8ec664f4ea3c67b95f415493b63581 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 20:12:16 +0800 Subject: [PATCH 34/64] =?UTF-8?q?fix(light-probe):=20=E6=B8=85=E7=A9=BA?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E4=B8=8D=E5=86=8D=E9=9A=8F=E6=92=A4=E9=94=80?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=B9=B6=E4=BF=9D=E7=95=99=E6=9C=AA=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 7 +- src/core/scene/common/lightfx-bake.ts | 2 +- src/core/scene/common/undo.ts | 6 +- .../scene/scene-process/service/editor.ts | 9 +- .../scene-process/service/light-probe-bake.ts | 17 ++- src/core/scene/scene-process/service/undo.ts | 19 ++- .../commands/light-probe-clear-command.ts | 25 ++++ .../service/undo/scene-undo-manager.ts | 18 ++- src/core/scene/test/editor-save-as.test.ts | 13 ++ .../test/light-probe-clear-history.test.ts | 127 ++++++++++++++++++ .../test/lightfx-result-failures.test.ts | 24 +++- 11 files changed, 242 insertions(+), 25 deletions(-) create mode 100644 src/core/scene/scene-process/service/undo/commands/light-probe-clear-command.ts create mode 100644 src/core/scene/test/light-probe-clear-history.test.ts diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index a3ffc7255..a884e04f5 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -143,17 +143,18 @@ Scene runtime 先通过内部 `reserveSceneOperation` 取得宿主生成的事 } ``` -该操作清除当前场景全部探针的烘焙结果,通知引擎刷新,并作为一次 Undo 操作记录。成功结果中的 `probeCount` 表示处理的探针数量。 +该操作清除当前场景全部探针的烘焙结果并通知引擎刷新,按 Creator 3.8.8 行为不生成恢复旧 SH 的 Undo 记录。清空前的普通编辑历史仍可撤销/重做,但不会带回旧 SH;清空之后新发生的 Probe Bake/编辑保留自己的历史语义。成功结果中的 `probeCount` 表示处理的探针数量。编辑录制/组合或 Undo 应用进行中时拒绝提交 Clear,恢复清空前结果。 -Probe/Lightmap Bake/Clear 的 `saveScene` 默认是 `true`:完整成功后,当前结果作为 Undo 的已保存基线;Undo 回到旧结果会变脏,Redo 回到已保存结果恢复干净。显式传 `false` 时只修改内存并保留 dirty,调用方需要另行保存。 +Probe/Lightmap Bake/Clear 的 `saveScene` 默认是 `true`,显式传 `false` 时只修改内存并保留 dirty,调用方需要另行保存。Probe Clear 使用独立的非撤销修改标记:撤销普通编辑或清空历史都不能消除此标记,成功保存才消除;关闭/重新载入场景会重建会话状态。其他可撤销操作仍以完成录制后的 Undo 位置作为已保存基线。 ### 提交与保存失败 -Bake 按「确认原生产物提交 → 应用场景结果 → 完成 Undo 录制 → 可选保存」执行;Clear 无原生提交,先完成结果录制再保存。Host 的 `committed` 只表示产物不再被取消/超时/rollback 删除,不代表 Scene 已应用或保存,也不代表整个任务成功。 +Bake 按「确认原生产物提交 → 应用场景结果 → 完成 Undo 录制 → 可选保存」执行。Clear 无原生提交;Lightmap Clear 先完成结果录制再保存,Probe Clear 则提交非撤销结果和 dirty 标记后再保存。Host 的 `committed` 只表示产物不再被取消/超时/rollback 删除,不代表 Scene 已应用或保存,也不代表整个任务成功。 - 原生提交拒绝或回应丢失:不应用结果、不创建结果历史、不保存场景。Host 若实际上已提交,则新版本可能成为未引用资产;保留它,不强删、不自动重新 Bake。 - 结果应用或录制前失败:恢复旧内存,不保存。已确认提交的产物仍保留,避免把不可逆的资产提交误当成可回滚事务。 - Undo 已入栈后的保存失败/回应丢失:抛出包含 `LightFX result retained` 和原始原因的错误,**保留当前结果、Undo 和产物**。保存请求可能未写盘,也可能已写盘但没有返回确认;不能通过自动恢复旧内存或删除贴图来猜测磁盘状态。调用方应刷新实际结果,允许用户检查后重新保存或 Undo,不要把失败解释为“场景未改变”。 +- Probe Clear 保存失败同样保留当前清空结果,不自动恢复旧 SH,也不承诺 Undo 恢复;错误提示要求检查后重新保存。保存前失败保持 dirty,写入已完成但回应失败则以实际保存基线为准。 - 失败时不会额外标记已保存。保存尚未写盘时结果保持 dirty;若保存已确认完成后才发生外层回应错误,内存与已保存结果相同,可以保持 clean。dirty 不是保存失败原因或磁盘写入状态的唯一证据。 - 场景保存先等待 Terrain 资产保存。已注册 Terrain 服务抛错或批量结果报告失败时,不继续保存 `.scene`、不广播保存成功、不更新保存点;后一个 Terrain 成功也不能覆盖前一个失败。已经成功写入的 Terrain 文件不做猜测性回滚,失败项保留 dirty 供重试。 diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts index f3e73d7de..a760938b4 100644 --- a/src/core/scene/common/lightfx-bake.ts +++ b/src/core/scene/common/lightfx-bake.ts @@ -21,7 +21,7 @@ export interface ILightProbeBakeCapabilities { cancelVersion?: 1; /** Advisory readiness of this Scene's native probe operation; absent on older implementations. */ cancellable?: boolean; - /** SH Undo/Redo and multi-group scene reopening preserve baked results. */ + /** Probe Bake/ordinary edits support SH history and multi-group reopening; Clear does not restore old SH. */ resultLifecycleVersion: 1; /** Both Scene and host participate in the full Bake/Clear transaction reservation. */ sceneTransactionVersion: 1; diff --git a/src/core/scene/common/undo.ts b/src/core/scene/common/undo.ts index 2c0c9d235..76af33609 100644 --- a/src/core/scene/common/undo.ts +++ b/src/core/scene/common/undo.ts @@ -107,7 +107,7 @@ export interface IUndoService { /** 清空整个 undo/redo 栈,内部生命周期 API。 */ reset(): void; - /** 清空整个 undo/redo 栈。 */ + /** 清空整个 undo/redo 栈,但不丢弃非撤销修改的未保存标记。 */ clearHistory(): void; /** 当前场景有未保存变更时返回 true。 */ @@ -140,6 +140,9 @@ export interface IUndoService { */ markSaved(): void; + /** 内部:提交不可撤销的探针清空,保留普通编辑历史与未保存状态。 */ + commitLightProbeClear(): void; + /** * 当前存在进行中的录制时返回 true。 * 传入 uuid 时,只有该 uuid 被某个录制覆盖才返回 true。 @@ -171,6 +174,7 @@ export type IPublicUndoService = Omit< | 'endRecording' | 'cancelRecording' | 'hasActiveRecording' + | 'commitLightProbeClear' >; /** 给外部代理过滤层使用的公开 redo 命名空间。 */ diff --git a/src/core/scene/scene-process/service/editor.ts b/src/core/scene/scene-process/service/editor.ts index 0779d3d8e..a57517209 100644 --- a/src/core/scene/scene-process/service/editor.ts +++ b/src/core/scene/scene-process/service/editor.ts @@ -206,7 +206,7 @@ export class EditorService extends BaseService implements IEditor } const encode = await editor.open(assetInfo, params); - this._clearUndoHistory(); + this._clearUndoHistory(true); // 设置当前打开的编辑器 this.currentEditorUuid = assetInfo.uuid; @@ -257,7 +257,7 @@ export class EditorService extends BaseService implements IEditor const result = await editor.close({ save: params.save ?? true }); if (editor === this.editorMap.get(currentEditorUuid)) { - this._clearUndoHistory(); + this._clearUndoHistory(true); this.currentEditorUuid = null; } for (const [uuid, candidate] of this.editorMap) { @@ -506,9 +506,10 @@ export class EditorService extends BaseService implements IEditor Service.Script.suspend(Promise.resolve(this.reload({}))); } - private _clearUndoHistory(): void { + private _clearUndoHistory(resetSession = false): void { try { - Service.Undo?.clearHistory(); + if (resetSession) Service.Undo?.reset(); + else Service.Undo?.clearHistory(); } catch (_e) { // UndoService may not be registered during early editor setup. } diff --git a/src/core/scene/scene-process/service/light-probe-bake.ts b/src/core/scene/scene-process/service/light-probe-bake.ts index 3016fb8fe..19ff022e4 100644 --- a/src/core/scene/scene-process/service/light-probe-bake.ts +++ b/src/core/scene/scene-process/service/light-probe-bake.ts @@ -127,21 +127,26 @@ export class LightProbeBakeService extends BaseService imple const info: any = scene.globals.lightProbeInfo; const probes: any[] = info.data?.probes ?? []; const previous = this.snapshot(probes); - const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Clear light probes' }); try { info.onProbeBakeCleared(); await Service.Engine.repaintInEditMode(); - await finishSavedLightFXRecording(Service.Undo, undo, - options.saveScene !== false ? () => Service.Editor.save({}) : undefined); - return { probeCount: probes.length }; + Service.Undo.commitLightProbeClear(); } catch (error) { - if (error instanceof LightFXResultRetainedError) throw error; - Service.Undo.cancelRecording(undo); this.restore(probes, previous); info.onProbeBakeFinished(); await Service.Engine.repaintInEditMode(); throw error; } + // A rejected save may already have written the scene. Keep the clear result + // and its dirty marker; it is no longer an operation that Undo can revert. + if (options.saveScene !== false) { + try { + await Service.Editor.save({}); + } catch (error) { + throw new Error(`LightFX result retained in the scene; save was not confirmed. Check the scene before saving again. ${this.errorMessage(error)}`); + } + } + return { probeCount: probes.length }; } cancel(): Promise { diff --git a/src/core/scene/scene-process/service/undo.ts b/src/core/scene/scene-process/service/undo.ts index b89a9f981..e5a5618f4 100644 --- a/src/core/scene/scene-process/service/undo.ts +++ b/src/core/scene/scene-process/service/undo.ts @@ -8,6 +8,7 @@ import type { ISnapshotAdapter } from './undo/commands/snapshot-command'; import { restoreComponentSnapshotDump, restoreNodeSnapshotDump, snapshotMapsEqual } from './undo/commands/command-utils-shared'; import dumpUtil from './dump'; import { withLightProbeTransformScenes } from './scene/light-probe-transform'; +import { LightProbeClearCommand } from './undo/commands/light-probe-clear-command'; interface IRecordingComponentSnapshot { uuid: string; @@ -105,17 +106,22 @@ export class UndoService extends BaseService implements IUndoServic } reset(): void { - this.clearHistory(); + this._clearHistory(true); } clearHistory(): void { + this._clearHistory(false); + } + + private _clearHistory(reset: boolean): void { const wasDirty = this._undoMgr.isDirty(); const hadUndoState = this._undoMgr.canUndo() || this._undoMgr.canRedo() || this._undoMgr.isGroupActive() || this._undoMgr.hasActiveRecording(); - this._undoMgr.reset(); + if (reset) this._undoMgr.reset(); + else this._undoMgr.clearHistory(); this._emitDirtyIfChanged(wasDirty); if (hadUndoState) { this.broadcast('undo:changed'); @@ -202,6 +208,15 @@ export class UndoService extends BaseService implements IUndoServic this._emitDirtyIfChanged(wasDirty); } + commitLightProbeClear(): void { + const scene = cc.director.getScene(); + if (!scene) throw new Error('No scene is currently open.'); + const wasDirty = this._undoMgr.isDirty(); + this._undoMgr.commitNonUndoableChange(command => command instanceof LightProbeClearCommand + ? command : new LightProbeClearCommand(command, scene.uuid, () => cc.director.getScene())); + this._emitDirtyIfChanged(wasDirty); + } + hasActiveRecording(uuid?: string): boolean { return this._undoMgr.hasActiveRecording(uuid); } diff --git a/src/core/scene/scene-process/service/undo/commands/light-probe-clear-command.ts b/src/core/scene/scene-process/service/undo/commands/light-probe-clear-command.ts new file mode 100644 index 000000000..a0d44a3c0 --- /dev/null +++ b/src/core/scene/scene-process/service/undo/commands/light-probe-clear-command.ts @@ -0,0 +1,25 @@ +import type { Scene } from 'cc'; +import type { IUndoCommand, IUndoRedoResult } from '../../../../common'; + +/** Existing edits still undo normally, but cannot bring back SH from before Clear. */ +export class LightProbeClearCommand implements IUndoCommand { + readonly meta; + + constructor(private readonly command: IUndoCommand, private readonly sceneUuid: string, private readonly getScene: () => Scene | null) { + this.meta = command.meta; + } + + undo(): Promise { return this.apply('undo'); } + redo(): Promise { return this.apply('redo'); } + + private async apply(direction: 'undo' | 'redo'): Promise { + try { + return await this.command[direction](); + } finally { + // Also protect against partially applied failed commands. A replacement + // scene owns another history and must never receive this scene's result. + const scene = this.getScene(); + if (scene?.isValid && scene.uuid === this.sceneUuid) scene.globals.lightProbeInfo.onProbeBakeCleared(); + } + } +} diff --git a/src/core/scene/scene-process/service/undo/scene-undo-manager.ts b/src/core/scene/scene-process/service/undo/scene-undo-manager.ts index 301a632f2..2d1a191e0 100644 --- a/src/core/scene/scene-process/service/undo/scene-undo-manager.ts +++ b/src/core/scene/scene-process/service/undo/scene-undo-manager.ts @@ -36,6 +36,7 @@ class SceneUndoManager { private _commandArray: IUndoCommand[] = []; private _index = -1; private _lastSavedCommandId: string | null = null; + private _nonUndoableDirty = false; private _checkpointGeneration = 0; private _autoCommands: SceneUndoCommand[] = []; private _manualCommands: SceneUndoCommand[] = []; @@ -142,6 +143,7 @@ class SceneUndoManager { this._commandArray.length = 0; this._index = -1; this._lastSavedCommandId = null; + this._nonUndoableDirty = false; this._checkpointGeneration++; this._autoCommands.length = 0; this._manualCommands.length = 0; @@ -150,17 +152,29 @@ class SceneUndoManager { this._activeGroup = null; } - // reset 的对外别名(IUndoService 同时暴露 reset/clearHistory)。 + // Clearing history is not saving or discarding a non-Undo scene result. clearHistory(): void { + const nonUndoableDirty = this._nonUndoableDirty; this.reset(); + this._nonUndoableDirty = nonUndoableDirty; } markSaved(): void { this._lastSavedCommandId = this._currentCommandId(); + this._nonUndoableDirty = false; } isDirty(): boolean { - return this._lastSavedCommandId !== this._currentCommandId(); + return this._nonUndoableDirty || this._lastSavedCommandId !== this._currentCommandId(); + } + + /** Keep existing edits, but prevent their snapshots from reverting a non-Undo result. */ + commitNonUndoableChange(protect: (command: IUndoCommand) => IUndoCommand): void { + if (this.hasActiveRecording() || this.isGroupActive() || this.isApplying()) { + throw new Error('Cannot commit a non-Undo result while an edit is active.'); + } + this._commandArray = this._commandArray.map(protect); + this._nonUndoableDirty = true; } createCheckpoint(): IUndoCheckpoint { diff --git a/src/core/scene/test/editor-save-as.test.ts b/src/core/scene/test/editor-save-as.test.ts index 54eb79231..1a51f0157 100644 --- a/src/core/scene/test/editor-save-as.test.ts +++ b/src/core/scene/test/editor-save-as.test.ts @@ -18,9 +18,11 @@ jest.mock('../scene-process/service/core', () => ({ protected broadcast() { } }, register: () => (target: unknown) => target, + queryRegisteredService: () => undefined, Service: { Undo: { clearHistory: jest.fn(), + reset: jest.fn(), markSaved: jest.fn(), }, }, @@ -50,6 +52,17 @@ describe('EditorService Save As', () => { globalEventEmitter.removeAllListeners(); }); + it('distinguishes a new scene session from history reset during in-memory reload', () => { + const { Service } = require('../scene-process/service/core'); + Service.Undo.reset.mockClear(); + Service.Undo.clearHistory.mockClear(); + editorService._clearUndoHistory(true); + expect(Service.Undo.reset).toHaveBeenCalledTimes(1); + expect(Service.Undo.clearHistory).not.toHaveBeenCalled(); + editorService._clearUndoHistory(); + expect(Service.Undo.clearHistory).toHaveBeenCalledTimes(1); + }); + it('requires Save As for a target other than the existing source asset', async () => { const sourceUuid = 'source-uuid'; const target = { uuid: 'target-uuid', url: 'db://assets/copied.scene', type: 'scene' }; diff --git a/src/core/scene/test/light-probe-clear-history.test.ts b/src/core/scene/test/light-probe-clear-history.test.ts new file mode 100644 index 000000000..2ff9c2554 --- /dev/null +++ b/src/core/scene/test/light-probe-clear-history.test.ts @@ -0,0 +1,127 @@ +import type { Scene } from 'cc'; +import { SceneUndoManager } from '../scene-process/service/undo/scene-undo-manager'; +import { LightProbeClearCommand } from '../scene-process/service/undo/commands/light-probe-clear-command'; + +function fixture() { + const state = { gi: 1, coefficients: [1, 2, 3] }; + const scene = { uuid: 'scene', isValid: true, globals: { lightProbeInfo: { onProbeBakeCleared() { state.coefficients = []; } } } }; + const manager = new SceneUndoManager({ snapshotAdapter: { + capture: () => new Map([['scene', structuredClone(state)]]), + equals: (a, b) => JSON.stringify([...a]) === JSON.stringify([...b]), + apply: data => { Object.assign(state, structuredClone(data.get('scene'))); return { success: true }; }, + } }); + const edit = async (gi: number, coefficients = state.coefficients) => { + const id = manager.beginRecording(['scene']); + Object.assign(state, { gi, coefficients }); + await manager.endRecording(id); + }; + const clear = () => { + manager.commitNonUndoableChange(command => command instanceof LightProbeClearCommand + ? command : new LightProbeClearCommand(command, scene.uuid, () => scene as unknown as Scene)); + scene.globals.lightProbeInfo.onProbeBakeCleared(); + }; + return { state, scene, manager, edit, clear }; +} + +describe('non-Undo probe Clear', () => { + it('is dirty without adding history, and stays dirty until saved', async () => { + const f = fixture(); + f.clear(); + await f.manager.undo(); + expect([f.state.coefficients, f.manager.isDirty(), f.manager.canUndo()]).toEqual([[], true, false]); + f.manager.clearHistory(); + expect(f.manager.isDirty()).toBe(true); + f.manager.markSaved(); + expect(f.manager.isDirty()).toBe(false); + f.clear(); + f.manager.reset(); + expect(f.manager.isDirty()).toBe(false); + }); + + it('keeps ordinary Undo/Redo and never restores pre-Clear SH even after saving', async () => { + const f = fixture(); + await f.edit(2); + f.clear(); + await f.manager.undo(); + expect([f.state, f.manager.isDirty()]).toEqual([{ gi: 1, coefficients: [] }, true]); + f.manager.markSaved(); + await f.manager.redo(); + expect([f.state, f.manager.isDirty()]).toEqual([{ gi: 2, coefficients: [] }, true]); + await f.manager.undo(); + expect([f.state, f.manager.isDirty()]).toEqual([{ gi: 1, coefficients: [] }, false]); + }); + + it('protects the existing redo branch as well as the undo branch', async () => { + const f = fixture(); + await f.edit(2); + await f.manager.undo(); + f.clear(); + await f.manager.redo(); + expect(f.state).toEqual({ gi: 2, coefficients: [] }); + }); + + it('does not change the result lifecycle of a later Probe Bake', async () => { + const f = fixture(); + await f.edit(2); + f.clear(); + await f.edit(3, [9]); + await f.manager.undo(); + await f.manager.undo(); + expect(f.state).toEqual({ gi: 1, coefficients: [] }); + await f.manager.redo(); + expect(f.state).toEqual({ gi: 2, coefficients: [] }); + await f.manager.redo(); + expect(f.state).toEqual({ gi: 3, coefficients: [9] }); + }); + + it('handles repeated Clear and composite histories without adding wrappers repeatedly', async () => { + const f = fixture(); + const group = f.manager.beginGroup(); + await f.edit(2); + await f.edit(3); + f.manager.endGroup(group); + f.clear(); + const protectedCommand = f.manager.getHistoryForTesting()[0]; + f.clear(); + expect(f.manager.getHistoryForTesting()[0]).toBe(protectedCommand); + await f.manager.undo(); + expect(f.state).toEqual({ gi: 1, coefficients: [] }); + await f.manager.redo(); + expect(f.state).toEqual({ gi: 3, coefficients: [] }); + }); + + it('rejects active edits without changing history or dirty state', () => { + const f = fixture(); + const id = f.manager.beginRecording(['scene']); + expect(() => f.clear()).toThrow('edit is active'); + f.manager.cancelRecording(id); + const group = f.manager.beginGroup(); + expect(() => f.clear()).toThrow('edit is active'); + f.manager.cancelGroup(group); + expect([f.state.coefficients, f.manager.isDirty(), f.manager.canUndo()]).toEqual([[1, 2, 3], false, false]); + }); + + it('protects a reloaded instance of the same scene, but never a different scene', async () => { + const f = fixture(); + await f.edit(2); + const clear = jest.fn(); + let current = { ...f.scene, globals: { lightProbeInfo: { onProbeBakeCleared: clear } } }; + const command = new LightProbeClearCommand(f.manager.getHistoryForTesting()[0], f.scene.uuid, () => current as unknown as Scene); + await command.undo(); + expect(clear).toHaveBeenCalledTimes(1); + current = { ...current, uuid: 'other-scene' }; + await command.redo(); + expect(clear).toHaveBeenCalledTimes(1); + }); + + it('clears SH even when an old command partially applies and then fails', async () => { + const f = fixture(); + const command = new LightProbeClearCommand({ + meta: { id: 'failed-edit', label: 'Failed edit', type: 'test', scope: {}, timestamp: 0 }, + async undo() { f.state.coefficients = [9]; throw new Error('partial failure'); }, + async redo() { return { success: true }; }, + }, f.scene.uuid, () => f.scene as unknown as Scene); + await expect(command.undo()).rejects.toThrow('partial failure'); + expect(f.state.coefficients).toEqual([]); + }); +}); diff --git a/src/core/scene/test/lightfx-result-failures.test.ts b/src/core/scene/test/lightfx-result-failures.test.ts index c6f013139..7abef00ba 100644 --- a/src/core/scene/test/lightfx-result-failures.test.ts +++ b/src/core/scene/test/lightfx-result-failures.test.ts @@ -10,7 +10,7 @@ class MockVec3 { } const mockBake = jest.fn(), mockCommit = jest.fn(), mockRollback = jest.fn(); const mockSave = jest.fn(), mockRepaint = jest.fn(); -const mockUndo = { beginRecording: jest.fn(), endRecording: jest.fn(), cancelRecording: jest.fn(), createCheckpoint: jest.fn() }; +const mockUndo = { beginRecording: jest.fn(), endRecording: jest.fn(), cancelRecording: jest.fn(), createCheckpoint: jest.fn(), commitLightProbeClear: jest.fn() }; jest.mock('cc', () => ({ director: { getScene: mockGetScene }, MeshRenderer: mockMeshRenderer, Terrain: mockTerrain, Vec3: MockVec3, SH: { getBasisCount: () => 9 } })); jest.mock('../scene-process/service/core', () => ({ @@ -30,6 +30,7 @@ jest.mock('../scene-process/rpc', () => ({ Rpc: { getInstance: jest.fn() } })); import { LightmapBakeService } from '../scene-process/service/lightmap-bake'; import { LightProbeBakeService } from '../scene-process/service/light-probe-bake'; import { SceneUndoManager } from '../scene-process/service/undo/scene-undo-manager'; +import { LightProbeClearCommand } from '../scene-process/service/undo/commands/light-probe-clear-command'; function fixture(target: 'probe' | 'lightmap') { const events: string[] = []; @@ -44,7 +45,7 @@ function fixture(target: 'probe' | 'lightmap') { } }; const probes = Array.from({ length: 4 }, (_, x) => ({ position: new MockVec3(x), normal: new MockVec3(), coefficients: [new MockVec3(1)] })); const info = { data: { probes }, giScale: 1, onProbeBakeFinished() {}, onProbeBakeCleared() { probes.forEach(p => { p.coefficients = []; }); } }; - const scene = { uuid: 'scene', name: 'test', globals: { lightProbeInfo: info, bakedWithHighpLightmap: false, bakedWithStationaryMainLight: false }, + const scene = { uuid: 'scene', name: 'test', isValid: true, globals: { lightProbeInfo: info, bakedWithHighpLightmap: false, bakedWithStationaryMainLight: false }, children: [], getComponents: (type: unknown) => type === mockMeshRenderer ? [model] : [], }; const read = () => ({ texture: model.bakeSettings.texture?.uuid ?? null, uv: model.bakeSettings.uvParam.clone(), @@ -68,6 +69,8 @@ function fixture(target: 'probe' | 'lightmap') { mockUndo.endRecording.mockImplementation(async id => { events.push('record'); await manager.endRecording(id); }); mockUndo.cancelRecording.mockImplementation(id => manager.cancelRecording(id)); mockUndo.createCheckpoint.mockImplementation(() => manager.createCheckpoint()); + mockUndo.commitLightProbeClear.mockImplementation(() => manager.commitNonUndoableChange(command => + new LightProbeClearCommand(command, scene.uuid, () => scene as unknown as import('cc').Scene))); const save = async () => { events.push('save'); disk = read(); manager.markSaved(); }; mockSave.mockImplementation(save); mockCommit.mockImplementation(async () => { events.push('commit'); committed = true; }); @@ -103,7 +106,7 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t expect(mockUndo.beginRecording).not.toHaveBeenCalled(); }); - it.each(['bake', 'clear'] as const)('retains %s after pre-write save failure, supports Undo/Redo and retry', async action => { + it.each(['bake', 'clear'] as const)('retains %s after pre-write save failure with its operation-specific Undo contract', async action => { const f = fixture(target); mockSave.mockRejectedValueOnce(new Error('disk unavailable')); await expect(action === 'bake' ? f.bake() : f.service.clearBake()).rejects.toThrow('result retained'); @@ -113,7 +116,7 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); expect(mockRollback).not.toHaveBeenCalled(); await f.manager.undo(); - expect(f.read()).toEqual(f.old); + expect(f.read()).toEqual(target === 'probe' && action === 'clear' ? result : f.old); await f.manager.redo(); expect(f.read()).toEqual(result); await f.save(); @@ -129,8 +132,9 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); expect(mockRollback).not.toHaveBeenCalled(); await f.manager.undo(); - expect(f.read()).toEqual(f.old); - expect(f.manager.isDirty()).toBe(true); + const nonUndoable = target === 'probe' && action === 'clear'; + expect(f.read()).toEqual(nonUndoable ? result : f.old); + expect(f.manager.isDirty()).toBe(!nonUndoable); await f.manager.redo(); expect({ memory: f.read(), disk: f.disk(), assets: f.assets(), dirty: f.manager.isDirty() }).toEqual({ memory: result, disk: result, assets: ['old', 'new'], dirty: false, @@ -147,4 +151,12 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t expect(mockSave).not.toHaveBeenCalled(); expect(mockRollback).not.toHaveBeenCalled(); }); + + it('restores Clear when application fails before committing the result', async () => { + const f = fixture(target); + mockRepaint.mockRejectedValueOnce(new Error('clear repaint failed')); + await expect(f.service.clearBake({ saveScene: false })).rejects.toThrow('clear repaint failed'); + expect({ memory: f.read(), dirty: f.manager.isDirty(), undo: f.manager.canUndo() }).toEqual({ memory: f.old, dirty: false, undo: false }); + expect(mockSave).not.toHaveBeenCalled(); + }); }); From 4153fd27f867bb63482d175914c1416fa7e764a1 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 20:23:02 +0800 Subject: [PATCH 35/64] =?UTF-8?q?fix(lightmap):=20=E9=87=8D=E7=83=98?= =?UTF-8?q?=E7=84=99=E5=90=8E=E6=92=A4=E9=94=80=E6=99=AE=E9=80=9A=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E6=97=B6=E4=BF=9D=E7=95=99=E6=9C=80=E6=96=B0=E8=B4=B4?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 10 ++-- src/core/scene/common/undo.ts | 4 ++ .../scene-process/service/lightmap-bake.ts | 56 +++++++++++++++++-- src/core/scene/scene-process/service/undo.ts | 17 +++++- .../commands/light-probe-clear-command.ts | 25 --------- .../undo/commands/lightfx-result-command.ts | 38 +++++++++++++ .../test/light-probe-clear-history.test.ts | 28 ++++++++-- .../test/lightfx-result-failures.test.ts | 53 +++++++++++++++--- .../test/lightmap-result-recording.test.ts | 35 ++++++++++-- 9 files changed, 213 insertions(+), 53 deletions(-) delete mode 100644 src/core/scene/scene-process/service/undo/commands/light-probe-clear-command.ts create mode 100644 src/core/scene/scene-process/service/undo/commands/lightfx-result-command.ts diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index a884e04f5..c01f2b7a2 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -149,12 +149,12 @@ Probe/Lightmap Bake/Clear 的 `saveScene` 默认是 `true`,显式传 `fals ### 提交与保存失败 -Bake 按「确认原生产物提交 → 应用场景结果 → 完成 Undo 录制 → 可选保存」执行。Clear 无原生提交;Lightmap Clear 先完成结果录制再保存,Probe Clear 则提交非撤销结果和 dirty 标记后再保存。Host 的 `committed` 只表示产物不再被取消/超时/rollback 删除,不代表 Scene 已应用或保存,也不代表整个任务成功。 +Bake 按「确认原生产物提交 → 应用场景结果 → 提交结果状态 → 可选保存」执行。首次 Lightmap Bake/Probe Bake 录制 Undo,已有绑定的 Lightmap 重烘焙则提交非撤销结果和 dirty 标记。Clear 无原生提交;Lightmap Clear 先完成结果录制再保存,Probe Clear 提交非撤销结果后再保存。Host 的 `committed` 只表示产物不再被取消/超时/rollback 删除,不代表 Scene 已应用或保存,也不代表整个任务成功。 - 原生提交拒绝或回应丢失:不应用结果、不创建结果历史、不保存场景。Host 若实际上已提交,则新版本可能成为未引用资产;保留它,不强删、不自动重新 Bake。 - 结果应用或录制前失败:恢复旧内存,不保存。已确认提交的产物仍保留,避免把不可逆的资产提交误当成可回滚事务。 - Undo 已入栈后的保存失败/回应丢失:抛出包含 `LightFX result retained` 和原始原因的错误,**保留当前结果、Undo 和产物**。保存请求可能未写盘,也可能已写盘但没有返回确认;不能通过自动恢复旧内存或删除贴图来猜测磁盘状态。调用方应刷新实际结果,允许用户检查后重新保存或 Undo,不要把失败解释为“场景未改变”。 -- Probe Clear 保存失败同样保留当前清空结果,不自动恢复旧 SH,也不承诺 Undo 恢复;错误提示要求检查后重新保存。保存前失败保持 dirty,写入已完成但回应失败则以实际保存基线为准。 +- Probe Clear/Lightmap 重烘焙保存失败同样保留当前结果,不自动恢复旧结果,也不承诺 Undo 恢复;错误提示要求检查后重新保存。保存前失败保持 dirty,写入已完成但回应失败则以实际保存基线为准。 - 失败时不会额外标记已保存。保存尚未写盘时结果保持 dirty;若保存已确认完成后才发生外层回应错误,内存与已保存结果相同,可以保持 clean。dirty 不是保存失败原因或磁盘写入状态的唯一证据。 - 场景保存先等待 Terrain 资产保存。已注册 Terrain 服务抛错或批量结果报告失败时,不继续保存 `.scene`、不广播保存成功、不更新保存点;后一个 Terrain 成功也不能覆盖前一个失败。已经成功写入的 Terrain 文件不做猜测性回滚,失败项保留 dirty 供重试。 @@ -383,12 +383,14 @@ LightFX 当前可能输出 Creator 历史协议版本。解析器只接受已知 - 已有另一个 LightFX 任务运行。 - 当前可见场景尚未加载完成,或同时存在多个可见的场景渲染器。 -Bake 和 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与结果修改的 MeshRenderer/Terrain 组件(同一 Terrain 多 block 去重)及 Scene 标记,不能只录制不递归的 Scene 根节点;旧引擎缺类型的空纹理引用也会保留在快照中。成功自动保存后以该记录作为保存点;saveScene:false 不隐式保存场景。失败必须区分原生提交前、结果应用中和结果录制后保存阶段,不能对所有错误统一恢复绑定或删除资产,详见“提交与保存失败”。 +已有绑定的 Lightmap 重烘焙不再生成恢复旧贴图的 Undo 记录,对齐 Creator 3.8.8 的重烘焙行为。此前普通编辑历史仍可执行,但本次烘焙对象的纹理/UV 和场景烘焙标记保持最新结果;内存重载后按组件 UUID 重新定位,不重新创建已经删除的对象。非撤销 dirty 标记直到保存才消除。首次 Bake 和 Lightmap Clear 的原有 Undo 语义暂保留,明确录制 MeshRenderer/Terrain 组件及 Scene 标记;不将已确认的重烘焙结论外推到未核实操作。失败仍须区分原生提交前、结果应用中和提交结果后的保存阶段,详见“提交与保存失败”。 -绑定历史与资产版本分别保留:新版本输出不会覆盖旧 PNG,Undo 通过旧 UUID 恢复旧纹理/UV/场景标记。此前旧版本 CLI 已覆盖丢失的像素无法靠此修复找回。`deleteAssets:true` 仍会删除同 sceneName 的整个 lightmap 目录,包括所有版本;不属于可恢复绑定的保留资产验证范围,调用方不可据此假定删除可撤销或跨同名场景安全。 +资产版本仍独立保留:未保存的新结果不能覆盖磁盘旧场景仍引用的 PNG,不能再以“重烘焙可撤销”为保留理由。首次 Bake/Clear 尚可撤销的引用保护仍有效。此前旧版本 CLI 已覆盖丢失的像素无法靠此修复找回。`deleteAssets:true` 仍会删除同 sceneName 的整个 lightmap 目录,包括所有版本;调用方不可据此假定删除可撤销或跨同名场景安全。 ## 验证范围 +以下旧轮次的 Bake/Clear Undo 记录是历史实现证据,不代表当前 Creator 对齐契约。后续收敛已改为 Probe Clear/已有绑定的 Lightmap 重烘焙不恢复旧结果;普通编辑历史、dirty 和保存失败保护保留,首次 Bake/Lightmap Clear 暂不改变。 + 当前实现已经验证: - Light Probe Bake/Clear,包含 SH 数据保存和重新加载。 diff --git a/src/core/scene/common/undo.ts b/src/core/scene/common/undo.ts index 76af33609..2a6be580f 100644 --- a/src/core/scene/common/undo.ts +++ b/src/core/scene/common/undo.ts @@ -143,6 +143,9 @@ export interface IUndoService { /** 内部:提交不可撤销的探针清空,保留普通编辑历史与未保存状态。 */ commitLightProbeClear(): void; + /** 内部:提交不可撤销的重烘焙,旧历史执行后重新应用最新绑定。 */ + commitLightmapRebake(restore: () => Promise): void; + /** * 当前存在进行中的录制时返回 true。 * 传入 uuid 时,只有该 uuid 被某个录制覆盖才返回 true。 @@ -175,6 +178,7 @@ export type IPublicUndoService = Omit< | 'cancelRecording' | 'hasActiveRecording' | 'commitLightProbeClear' + | 'commitLightmapRebake' >; /** 给外部代理过滤层使用的公开 redo 命名空间。 */ diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index 9a3aa08f0..8c8b1652b 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -77,25 +77,37 @@ export class LightmapBakeService extends BaseService impleme const previousBindings = this.snapshotBindings(output); const previousHighp = (scene.globals as any).bakedWithHighpLightmap; const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; + const rebake = previousBindings.some(binding => binding.texture); // Scene recordings do not recursively capture child components. // Keep the flags last, after restoring each affected result binding. const targets = [...new Set([...output.models, ...output.terrains].map(component => component.uuid)), scene.uuid]; - const undo = Service.Undo.beginRecording(targets, { label: 'Bake lightmap' }); + const undo = rebake ? undefined : Service.Undo.beginRecording(targets, { label: 'Bake lightmap' }); try { this.applyBakeResult(output, textures); (scene.globals as any).bakedWithHighpLightmap = settings.highp; (scene.globals as any).bakedWithStationaryMainLight = output.stationaryMainLight; await Service.Engine.repaintInEditMode(); - await finishSavedLightFXRecording(Service.Undo, undo, - options.saveScene !== false ? () => Service.Editor.save({}) : undefined); + if (rebake) { + Service.Undo.commitLightmapRebake(this.retainBakeResult(scene, output)); + } else { + await finishSavedLightFXRecording(Service.Undo, undo!, + options.saveScene !== false ? () => Service.Editor.save({}) : undefined); + } } catch (error) { if (error instanceof LightFXResultRetainedError) throw error; this.restoreBindings(previousBindings); (scene.globals as any).bakedWithHighpLightmap = previousHighp; (scene.globals as any).bakedWithStationaryMainLight = previousStationary; - Service.Undo.cancelRecording(undo); + if (undo) Service.Undo.cancelRecording(undo); throw error; } + if (rebake && options.saveScene !== false) { + try { + await Service.Editor.save({}); + } catch (error) { + throw new Error(`LightFX result retained in the scene; save was not confirmed. Check the scene before saving again. ${this.errorMessage(error)}`); + } + } this.broadcast('lightfx:bake-end', 'lightmap'); return { @@ -267,6 +279,42 @@ export class LightmapBakeService extends BaseService impleme ]; } + /** Resolve component identities again after Undo/reload; never resurrect removed objects. */ + private retainBakeResult(scene: Scene, output: LightFXBakeOutput): () => Promise { + const sceneUuid = scene.uuid; + const bindings = this.snapshotBindings(output).map(binding => ({ + uuid: binding.target.uuid as string, blockId: binding.blockId, texture: binding.texture, uv: binding.uv, + })); + const targets = new Set([...output.models, ...output.terrains].map(component => component.uuid)); + const highp = scene.globals.bakedWithHighpLightmap; + const stationary = scene.globals.bakedWithStationaryMainLight; + return async () => { + const current = director.getScene(); + if (!current || current.uuid !== sceneUuid) return; + const components = new Map(); + const visit = (node: any): void => { + for (const component of [...node.getComponents(MeshRenderer), ...node.getComponents(Terrain)]) { + if (targets.has(component.uuid)) components.set(component.uuid, component); + } + node.children.forEach(visit); + }; + visit(current); + const restored: LightmapBinding[] = []; + for (const binding of bindings) { + const target = components.get(binding.uuid); + if (!target) continue; + if (binding.texture && !binding.texture.isValid) { + binding.texture = await this.loadTexture(binding.texture.uuid, 60_000); + } + restored.push({ ...binding, target }); + } + this.clearBindings(this.snapshotSceneBindings(current).filter(binding => targets.has(binding.target.uuid))); + this.restoreBindings(restored); + current.globals.bakedWithHighpLightmap = highp; + current.globals.bakedWithStationaryMainLight = stationary; + }; + } + private snapshotSceneBindings(scene: Scene): LightmapBinding[] { const bindings: LightmapBinding[] = []; const visit = (node: any): void => { diff --git a/src/core/scene/scene-process/service/undo.ts b/src/core/scene/scene-process/service/undo.ts index e5a5618f4..7a94d9487 100644 --- a/src/core/scene/scene-process/service/undo.ts +++ b/src/core/scene/scene-process/service/undo.ts @@ -8,7 +8,7 @@ import type { ISnapshotAdapter } from './undo/commands/snapshot-command'; import { restoreComponentSnapshotDump, restoreNodeSnapshotDump, snapshotMapsEqual } from './undo/commands/command-utils-shared'; import dumpUtil from './dump'; import { withLightProbeTransformScenes } from './scene/light-probe-transform'; -import { LightProbeClearCommand } from './undo/commands/light-probe-clear-command'; +import { LightFXResultCommand } from './undo/commands/lightfx-result-command'; interface IRecordingComponentSnapshot { uuid: string; @@ -211,9 +211,20 @@ export class UndoService extends BaseService implements IUndoServic commitLightProbeClear(): void { const scene = cc.director.getScene(); if (!scene) throw new Error('No scene is currently open.'); + const uuid = scene.uuid; + this._commitLightFXResult('light-probe', () => { + const current = cc.director.getScene(); + if (current?.isValid && current.uuid === uuid) current.globals.lightProbeInfo.onProbeBakeCleared(); + }); + } + + commitLightmapRebake(restore: () => Promise): void { + this._commitLightFXResult('lightmap', restore); + } + + private _commitLightFXResult(target: 'light-probe' | 'lightmap', restore: () => void | Promise): void { const wasDirty = this._undoMgr.isDirty(); - this._undoMgr.commitNonUndoableChange(command => command instanceof LightProbeClearCommand - ? command : new LightProbeClearCommand(command, scene.uuid, () => cc.director.getScene())); + this._undoMgr.commitNonUndoableChange(command => LightFXResultCommand.protect(command, target, restore)); this._emitDirtyIfChanged(wasDirty); } diff --git a/src/core/scene/scene-process/service/undo/commands/light-probe-clear-command.ts b/src/core/scene/scene-process/service/undo/commands/light-probe-clear-command.ts deleted file mode 100644 index a0d44a3c0..000000000 --- a/src/core/scene/scene-process/service/undo/commands/light-probe-clear-command.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Scene } from 'cc'; -import type { IUndoCommand, IUndoRedoResult } from '../../../../common'; - -/** Existing edits still undo normally, but cannot bring back SH from before Clear. */ -export class LightProbeClearCommand implements IUndoCommand { - readonly meta; - - constructor(private readonly command: IUndoCommand, private readonly sceneUuid: string, private readonly getScene: () => Scene | null) { - this.meta = command.meta; - } - - undo(): Promise { return this.apply('undo'); } - redo(): Promise { return this.apply('redo'); } - - private async apply(direction: 'undo' | 'redo'): Promise { - try { - return await this.command[direction](); - } finally { - // Also protect against partially applied failed commands. A replacement - // scene owns another history and must never receive this scene's result. - const scene = this.getScene(); - if (scene?.isValid && scene.uuid === this.sceneUuid) scene.globals.lightProbeInfo.onProbeBakeCleared(); - } - } -} diff --git a/src/core/scene/scene-process/service/undo/commands/lightfx-result-command.ts b/src/core/scene/scene-process/service/undo/commands/lightfx-result-command.ts new file mode 100644 index 000000000..df341ce3a --- /dev/null +++ b/src/core/scene/scene-process/service/undo/commands/lightfx-result-command.ts @@ -0,0 +1,38 @@ +import type { IUndoCommand, IUndoRedoResult } from '../../../../common'; + +/** Old edits still undo normally, without reverting a later non-Undo LightFX result. */ +export class LightFXResultCommand implements IUndoCommand { + readonly meta; + private readonly results = new Map<'light-probe' | 'lightmap', () => void | Promise>(); + + private constructor(private readonly command: IUndoCommand) { + this.meta = command.meta; + } + + static protect(command: IUndoCommand, target: 'light-probe' | 'lightmap', restore: () => void | Promise): LightFXResultCommand { + const protectedCommand = command instanceof LightFXResultCommand ? command : new LightFXResultCommand(command); + // Replace, rather than nest, the same result after repeated Bake/Clear. + protectedCommand.results.set(target, restore); + return protectedCommand; + } + + undo(): Promise { return this.apply('undo'); } + redo(): Promise { return this.apply('redo'); } + + private async apply(direction: 'undo' | 'redo'): Promise { + const failures: unknown[] = []; + let result: IUndoRedoResult | undefined; + try { + result = await this.command[direction](); + } catch (error) { + failures.push(error); + } + // Partially applied failed commands must not resurrect old results either. + for (const restore of this.results.values()) { + try { await restore(); } catch (error) { failures.push(error); } + } + // A missing Lightmap texture must not skip the independent SH guard. + if (failures.length) throw failures[0]; + return result!; + } +} diff --git a/src/core/scene/test/light-probe-clear-history.test.ts b/src/core/scene/test/light-probe-clear-history.test.ts index 2ff9c2554..e2bc773e4 100644 --- a/src/core/scene/test/light-probe-clear-history.test.ts +++ b/src/core/scene/test/light-probe-clear-history.test.ts @@ -1,6 +1,14 @@ import type { Scene } from 'cc'; +import type { IUndoCommand } from '../common'; import { SceneUndoManager } from '../scene-process/service/undo/scene-undo-manager'; -import { LightProbeClearCommand } from '../scene-process/service/undo/commands/light-probe-clear-command'; +import { LightFXResultCommand } from '../scene-process/service/undo/commands/lightfx-result-command'; + +function protectClear(command: IUndoCommand, uuid: string, getScene: () => Scene) { + return LightFXResultCommand.protect(command, 'light-probe', () => { + const scene = getScene(); + if (scene.isValid && scene.uuid === uuid) scene.globals.lightProbeInfo.onProbeBakeCleared(); + }); +} function fixture() { const state = { gi: 1, coefficients: [1, 2, 3] }; @@ -16,8 +24,7 @@ function fixture() { await manager.endRecording(id); }; const clear = () => { - manager.commitNonUndoableChange(command => command instanceof LightProbeClearCommand - ? command : new LightProbeClearCommand(command, scene.uuid, () => scene as unknown as Scene)); + manager.commitNonUndoableChange(command => protectClear(command, scene.uuid, () => scene as unknown as Scene)); scene.globals.lightProbeInfo.onProbeBakeCleared(); }; return { state, scene, manager, edit, clear }; @@ -106,7 +113,7 @@ describe('non-Undo probe Clear', () => { await f.edit(2); const clear = jest.fn(); let current = { ...f.scene, globals: { lightProbeInfo: { onProbeBakeCleared: clear } } }; - const command = new LightProbeClearCommand(f.manager.getHistoryForTesting()[0], f.scene.uuid, () => current as unknown as Scene); + const command = protectClear(f.manager.getHistoryForTesting()[0], f.scene.uuid, () => current as unknown as Scene); await command.undo(); expect(clear).toHaveBeenCalledTimes(1); current = { ...current, uuid: 'other-scene' }; @@ -116,7 +123,7 @@ describe('non-Undo probe Clear', () => { it('clears SH even when an old command partially applies and then fails', async () => { const f = fixture(); - const command = new LightProbeClearCommand({ + const command = protectClear({ meta: { id: 'failed-edit', label: 'Failed edit', type: 'test', scope: {}, timestamp: 0 }, async undo() { f.state.coefficients = [9]; throw new Error('partial failure'); }, async redo() { return { success: true }; }, @@ -124,4 +131,15 @@ describe('non-Undo probe Clear', () => { await expect(command.undo()).rejects.toThrow('partial failure'); expect(f.state.coefficients).toEqual([]); }); + + it('still protects cleared SH when an independent Lightmap restore fails', async () => { + const f = fixture(); + await f.edit(2); + const command = LightFXResultCommand.protect(f.manager.getHistoryForTesting()[0], 'lightmap', async () => { + throw new Error('texture unavailable'); + }); + protectClear(command, f.scene.uuid, () => f.scene as unknown as Scene); + await expect(command.undo()).rejects.toThrow('texture unavailable'); + expect(f.state.coefficients).toEqual([]); + }); }); diff --git a/src/core/scene/test/lightfx-result-failures.test.ts b/src/core/scene/test/lightfx-result-failures.test.ts index 7abef00ba..efd26521d 100644 --- a/src/core/scene/test/lightfx-result-failures.test.ts +++ b/src/core/scene/test/lightfx-result-failures.test.ts @@ -10,7 +10,7 @@ class MockVec3 { } const mockBake = jest.fn(), mockCommit = jest.fn(), mockRollback = jest.fn(); const mockSave = jest.fn(), mockRepaint = jest.fn(); -const mockUndo = { beginRecording: jest.fn(), endRecording: jest.fn(), cancelRecording: jest.fn(), createCheckpoint: jest.fn(), commitLightProbeClear: jest.fn() }; +const mockUndo = { beginRecording: jest.fn(), endRecording: jest.fn(), cancelRecording: jest.fn(), createCheckpoint: jest.fn(), commitLightProbeClear: jest.fn(), commitLightmapRebake: jest.fn() }; jest.mock('cc', () => ({ director: { getScene: mockGetScene }, MeshRenderer: mockMeshRenderer, Terrain: mockTerrain, Vec3: MockVec3, SH: { getBasisCount: () => 9 } })); jest.mock('../scene-process/service/core', () => ({ @@ -30,11 +30,11 @@ jest.mock('../scene-process/rpc', () => ({ Rpc: { getInstance: jest.fn() } })); import { LightmapBakeService } from '../scene-process/service/lightmap-bake'; import { LightProbeBakeService } from '../scene-process/service/light-probe-bake'; import { SceneUndoManager } from '../scene-process/service/undo/scene-undo-manager'; -import { LightProbeClearCommand } from '../scene-process/service/undo/commands/light-probe-clear-command'; +import { LightFXResultCommand } from '../scene-process/service/undo/commands/lightfx-result-command'; function fixture(target: 'probe' | 'lightmap') { const events: string[] = []; - const oldTexture = { uuid: 'old' }, texture = { uuid: 'new' }; + const oldTexture = { uuid: 'old', isValid: true }, texture = { uuid: 'new', isValid: true }; let assets = ['old', 'new']; let committed = false; const model = { uuid: 'mesh', node: {}, bakeSettings: { texture: oldTexture as { uuid: string } | null, @@ -70,7 +70,11 @@ function fixture(target: 'probe' | 'lightmap') { mockUndo.cancelRecording.mockImplementation(id => manager.cancelRecording(id)); mockUndo.createCheckpoint.mockImplementation(() => manager.createCheckpoint()); mockUndo.commitLightProbeClear.mockImplementation(() => manager.commitNonUndoableChange(command => - new LightProbeClearCommand(command, scene.uuid, () => scene as unknown as import('cc').Scene))); + LightFXResultCommand.protect(command, 'light-probe', () => info.onProbeBakeCleared()))); + mockUndo.commitLightmapRebake.mockImplementation(restore => { + events.push('retain'); + manager.commitNonUndoableChange(command => LightFXResultCommand.protect(command, 'lightmap', restore)); + }); const save = async () => { events.push('save'); disk = read(); manager.markSaved(); }; mockSave.mockImplementation(save); mockCommit.mockImplementation(async () => { events.push('commit'); committed = true; }); @@ -83,7 +87,8 @@ function fixture(target: 'probe' | 'lightmap') { const service = target === 'probe' ? new LightProbeBakeService() : new LightmapBakeService(); jest.spyOn(service as any, 'querySceneUrl').mockResolvedValue('db://assets/test.scene'); if (service instanceof LightmapBakeService) jest.spyOn(service as any, 'loadOutputTextures').mockResolvedValue(new Map([['mesh:0', texture]])); - return { service, manager, read, disk: () => disk, assets: () => assets, events, save, + return { service, manager, read, disk: () => disk, assets: () => assets, events, save, model, scene, + editGi: async () => { const id = manager.beginRecording(['scene']); info.giScale = 7; await manager.endRecording(id); }, commit: async () => { committed = true; }, bake: () => service.bake({ giScale: 2, highp: true }), old: read() }; } @@ -92,7 +97,7 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t it('confirms asset retention before recording or saving', async () => { const f = fixture(target); await f.bake(); - expect({ events: f.events, disk: f.disk(), dirty: f.manager.isDirty() }).toEqual({ events: ['commit', 'record', 'save'], disk: f.read(), dirty: false }); + expect({ events: f.events, disk: f.disk(), dirty: f.manager.isDirty() }).toEqual({ events: ['commit', target === 'probe' ? 'record' : 'retain', 'save'], disk: f.read(), dirty: false }); }); it.each([false, true])('does not mutate scene, disk or history on commit failure (host committed=%s)', async committed => { @@ -116,7 +121,7 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); expect(mockRollback).not.toHaveBeenCalled(); await f.manager.undo(); - expect(f.read()).toEqual(target === 'probe' && action === 'clear' ? result : f.old); + expect(f.read()).toEqual((target === 'probe' && action === 'clear') || (target === 'lightmap' && action === 'bake') ? result : f.old); await f.manager.redo(); expect(f.read()).toEqual(result); await f.save(); @@ -132,7 +137,7 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); expect(mockRollback).not.toHaveBeenCalled(); await f.manager.undo(); - const nonUndoable = target === 'probe' && action === 'clear'; + const nonUndoable = (target === 'probe' && action === 'clear') || (target === 'lightmap' && action === 'bake'); expect(f.read()).toEqual(nonUndoable ? result : f.old); expect(f.manager.isDirty()).toBe(!nonUndoable); await f.manager.redo(); @@ -160,3 +165,35 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t expect(mockSave).not.toHaveBeenCalled(); }); }); + +describe('Lightmap rebake history', () => { + beforeEach(() => jest.resetAllMocks()); + it('keeps new texture, UV and flags while older GI edits still undo and redo', async () => { + const f = fixture('lightmap'); + await f.editGi(); + await f.service.bake({ saveScene: false, highp: true }); + const latest = f.read(); + await f.manager.undo(); + expect(f.read()).toEqual({ ...latest, giScale: 1 }); + expect(f.manager.isDirty()).toBe(true); + await f.manager.redo(); + expect(f.read()).toEqual(latest); + await f.save(); + expect(f.manager.isDirty()).toBe(false); + }); + it('leaves first Bake and later Lightmap Clear undoable', async () => { + const f = fixture('lightmap'); + f.model.bakeSettings.texture = null; + await f.service.bake({ saveScene: false }); + expect(mockUndo.beginRecording).toHaveBeenCalled(); + await f.manager.undo(); + expect(f.read().texture).toBeNull(); + await f.manager.redo(); + await f.service.bake({ saveScene: false }); + const latest = f.read(); + await f.service.clearBake({ saveScene: false }); + expect(f.read().texture).toBeNull(); + await f.manager.undo(); + expect(f.read()).toEqual(latest); + }); +}); diff --git a/src/core/scene/test/lightmap-result-recording.test.ts b/src/core/scene/test/lightmap-result-recording.test.ts index bd295ce94..15e1ff6ec 100644 --- a/src/core/scene/test/lightmap-result-recording.test.ts +++ b/src/core/scene/test/lightmap-result-recording.test.ts @@ -10,6 +10,7 @@ const mockUndo = { cancelRecording: jest.fn(), createCheckpoint: jest.fn(() => ({ commandId: 'recording', generation: 1 })), markSaved: jest.fn(), + commitLightmapRebake: jest.fn(), }; const mockSave = jest.fn(async () => undefined); jest.mock('cc', () => ({ director: { getScene: mockGetScene }, MeshRenderer: mockMeshRenderer, Terrain: mockTerrain })); @@ -39,7 +40,7 @@ function fixture() { mockGetScene.mockReturnValue(scene); const service = new LightmapBakeService(); jest.spyOn(service as any, 'querySceneUrl').mockResolvedValue('db://assets/test.scene'); - const texture = { uuid: 'new-texture' }; + const texture = { uuid: 'new-texture', isValid: true }; jest.spyOn(service as any, 'loadOutputTextures').mockResolvedValue(new Map([['mesh:0', texture], ['terrain:0', texture]])); mockBake.mockResolvedValue({ models: [model], terrains: [terrain], operationId: 'operation', stationaryMainLight: true, textureUrls: [], result: { meshes: [{ id: 0, index: 0, offset: [0.1, 0.2], scale: [0.3, 0.4] }], @@ -50,13 +51,14 @@ function fixture() { describe('Lightmap result recording targets', () => { beforeEach(() => jest.clearAllMocks()); - it.each([false, true])('records Mesh and Terrain components before scene flags for Bake (save=%s)', async saveScene => { + it.each([false, true])('retains Mesh and Terrain rebake results without adding Undo (save=%s)', async saveScene => { const f = fixture(); await f.service.bake({ saveScene }); - expect(mockUndo.beginRecording).toHaveBeenCalledWith(['mesh', 'terrain', 'scene'], { label: 'Bake lightmap' }); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); expect(f.model._updateLightmap).toHaveBeenCalledWith(f.texture, 0.1, 0.2, 0.3, 0.4); expect(f.terrain._updateLightmap).toHaveBeenCalledWith(1, f.texture, 0.5, 0.6, 0.7, 0.8); - expect(mockUndo.endRecording).toHaveBeenCalledWith('recording'); + expect(mockUndo.endRecording).not.toHaveBeenCalled(); + expect(mockUndo.commitLightmapRebake).toHaveBeenCalledWith(expect.any(Function)); expect(mockCommit).toHaveBeenCalledWith('operation'); expect(mockSave).toHaveBeenCalledTimes(saveScene ? 1 : 0); expect(mockUndo.markSaved).not.toHaveBeenCalled(); @@ -82,4 +84,29 @@ describe('Lightmap result recording targets', () => { expect(f.model._updateLightmap).toHaveBeenLastCalledWith(null, 0, 0, 0, 0); expect(f.terrain._updateLightmap).toHaveBeenLastCalledWith(1, null, 0, 0, 0, 0); }); + + it('resolves reloaded Terrain blocks by identity, clears obsolete blocks and leaves unrelated objects alone', async () => { + const f = fixture(); + f.terrain._resetLightmap.mockImplementation(() => { f.terrain._lightmapInfos = []; }); + f.terrain._updateLightmap.mockImplementation((blockId, texture, UOff, VOff, UScale, VScale) => { + f.terrain._lightmapInfos[blockId] = { texture, UOff, VOff, UScale, VScale }; + }); + await f.service.bake({ saveScene: false, highp: true }); + const oldScene = mockGetScene(); + const replacement = { ...f.terrain, _lightmapInfos: [ + { texture: f.oldTexture, UOff: 1, VOff: 2, UScale: 3, VScale: 4 }, + { texture: f.oldTexture, UOff: 5, VOff: 6, UScale: 7, VScale: 8 }, + ], _updateLightmap: jest.fn() }; + const unrelated = { ...f.model, uuid: 'unrelated', _updateLightmap: jest.fn() }; + const current = { ...oldScene, globals: { bakedWithHighpLightmap: false, bakedWithStationaryMainLight: false }, + getComponents: (type: unknown) => type === mockMeshRenderer ? [unrelated] : type === mockTerrain ? [replacement] : [], + }; + mockGetScene.mockReturnValue(current); + await mockUndo.commitLightmapRebake.mock.calls[0][0](); + expect(replacement._updateLightmap.mock.calls).toEqual([ + [0, null, 0, 0, 0, 0], [1, null, 0, 0, 0, 0], [1, f.texture, 0.5, 0.6, 0.7, 0.8], + ]); + expect(unrelated._updateLightmap).not.toHaveBeenCalled(); + expect(current.globals).toEqual({ bakedWithHighpLightmap: true, bakedWithStationaryMainLight: true }); + }); }); From 11d5d5dab76f2ce46ac5a4ed9c74be96c7d3c8ae Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 21:04:01 +0800 Subject: [PATCH 36/64] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=85=89=E7=85=A7=E8=B4=B4=E5=9B=BE=E7=83=98=E7=84=99?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 5 ++- src/api/scene/lightfx-bake-schema.ts | 1 + src/core/scene/common/lightfx-bake.ts | 4 ++ src/core/scene/common/lightfx-host.ts | 3 ++ .../scene/main-process/lightfx-bake-host.ts | 29 +++++++++++--- .../service/baking/lightfx/baker.ts | 6 ++- .../scene-process/service/lightmap-bake.ts | 3 +- .../scene/test/lightfx-asset-versions.test.ts | 39 +++++++++++++++++-- src/core/scene/test/lightfx-bake-host.test.ts | 2 +- .../scene/test/lightfx-cancel-owner.test.ts | 12 ++++++ .../test/lightfx-scene-entrances.test.ts | 6 +++ 11 files changed, 97 insertions(+), 13 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index c01f2b7a2..bfb339844 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -189,6 +189,7 @@ Bake 按「确认原生产物提交 → 应用场景结果 → 提交结果状 | 参数 | 范围 | CLI 默认值 | | --- | --- | --- | +| `outputUrl` | 已存在的 `db://assets` 内目录 URL,仅 Lightmap 支持 | `db://assets//lightmap` | | `msaa` | 1、2、4、8 | 4 | | `resolution` | 128、256、512、1024、2048 | 1024 | | `filter` | boolean | `true` | @@ -331,12 +332,14 @@ Scene 侧 `LightProbeBake.cancel()`/`LightmapBake.cancel()` 只取消本 rende ## Lightmap 资产规则 -Lightmap 按每次烘焙的 operation UUID 输出到独立版本目录(以下为路径模板): +Lightmap 按每次烘焙的 operation UUID 输出到独立版本目录(以下为默认路径模板): ```text db://assets//lightmap/bake-/ ``` +指定 `outputUrl` 时改为 `/bake-/`,例如 `db://assets/烘焙结果 Room A`。目录必须已存在且真实路径位于当前项目 assets 内;不接受任意磁盘路径、路径穿越或指向 assets 外的符号链接。参数仅改变本次输出位置,不自动保存为场景设置。Scene 的 `queryCapabilities().outputDirectory === true` 来自实际 Host 的 `lightmapOutputDirectory` 支持位;旧 Host 不支持时明确报错,不忽略选择后写入默认目录。省略参数仍沿用原路径。 + 典型文件包括: ```text diff --git a/src/api/scene/lightfx-bake-schema.ts b/src/api/scene/lightfx-bake-schema.ts index ec6755655..7bac75455 100644 --- a/src/api/scene/lightfx-bake-schema.ts +++ b/src/api/scene/lightfx-bake-schema.ts @@ -24,6 +24,7 @@ export const SchemaLightProbeBakeResult = z.object({ }); export const SchemaLightmapBakeOptions = z.object({ + outputUrl: z.string().optional().describe('Existing output directory under db://assets; each bake creates an immutable child directory. Defaults to the scene lightmap directory.'), msaa: z.union([z.literal(1), z.literal(2), z.literal(4), z.literal(8)]).optional(), resolution: z.union([z.literal(128), z.literal(256), z.literal(512), z.literal(1024), z.literal(2048)]).optional(), filter: z.boolean().optional(), highp: z.boolean().optional(), diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts index a760938b4..1d5e09244 100644 --- a/src/core/scene/common/lightfx-bake.ts +++ b/src/core/scene/common/lightfx-bake.ts @@ -44,6 +44,8 @@ export interface ILightProbeBakeResult { } export interface ILightmapBakeOptions { + /** Existing assets directory URL. Each bake publishes an immutable child directory; omitted uses the scene's default. */ + outputUrl?: string; msaa?: 1 | 2 | 4 | 8; resolution?: 128 | 256 | 512 | 1024 | 2048; filter?: boolean; @@ -62,6 +64,8 @@ export interface ILightmapBakeOptions { /** Implementation support, not native executable readiness, task recovery or safe asset deletion. */ export interface ILightmapBakeCapabilities { + /** The actual host accepts a selected assets output directory. */ + outputDirectory?: true; diagnostics?: ILightFXDiagnostics; version: 1; /** Mesh/Terrain bindings, null references and live blocks are restored with the result history. */ diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts index 02cdf12b8..e738379a6 100644 --- a/src/core/scene/common/lightfx-host.ts +++ b/src/core/scene/common/lightfx-host.ts @@ -16,6 +16,8 @@ export interface ILightFXHostCapabilities { sceneTransactionVersion: 1; /** Absent on legacy hosts; version 1 publishes immutable per-operation Lightmap assets. */ lightmapAssetVersion?: 1; + /** Accepts an existing assets directory as the Lightmap output parent. */ + lightmapOutputDirectory?: true; /** Version 1 requires the exact native operation, target and scene reservation to cancel. */ cancelOwnershipVersion?: 1; diagnosticsVersion?: 1; @@ -47,6 +49,7 @@ export interface IResolvedLightFXTextureSource { } export interface IBeginLightFXBakeOptions { + outputUrl?: string; transactionId?: string; target: LightFXBakeTarget; sceneName: string; diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index 1139f8eaa..7d2fba1b4 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -7,10 +7,11 @@ import { pathExists, readFile, readdir, + realpath, remove, stat, } from 'fs-extra'; -import { basename, dirname, join } from 'path'; +import { basename, dirname, isAbsolute, join, relative, sep } from 'path'; import Utils from '../../base/utils'; import type { IAppendLightFXInputOptions, @@ -84,7 +85,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { private readonly releasedSceneOperations = new Set(); public async queryCapabilities(): Promise { - return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; + return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; } public async queryDiagnostics(options: ICancelLightFXOperationOptions): Promise { @@ -208,8 +209,10 @@ export class LightFXBakeHost implements ILightFXBakeHostService { // Published textures are immutable: existing saved scenes and Undo // records may still refer to any earlier bake, including legacy files. const version = `bake-${operationId}`; - const targetDir = join(assetRoot, options.sceneName, 'lightmap', version); - const targetUrl = `db://assets/${options.sceneName}/lightmap/${version}`; + const parentUrl = options.outputUrl ?? `db://assets/${options.sceneName}/lightmap`; + const parentDir = join(assetRoot, parentUrl.slice('db://assets'.length)); + const targetDir = join(parentDir, version); + const targetUrl = `${parentUrl}/${version}`; const operation: LightFXHostOperation = { id: operationId, target: options.target, @@ -220,7 +223,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { outputDir, targetDir, targetUrl, - refreshUrl: `db://assets/${options.sceneName}`, + refreshUrl: options.outputUrl ?? `db://assets/${options.sceneName}`, inputBytes: 0, inputWritePromise: Promise.resolve(), state: 'accepting-input', @@ -238,6 +241,12 @@ export class LightFXBakeHost implements ILightFXBakeHostService { if (this.diagnostics.size > MAX_REMEMBERED_OPERATIONS) { this.diagnostics.delete(this.diagnostics.keys().next().value!); } if (this.sceneOperation) this.sceneOperation.nativeStarted = true; try { + if (options.outputUrl !== undefined) { + const path = relative(await realpath(assetRoot), await realpath(parentDir)); + if (path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path) || !(await stat(parentDir)).isDirectory()) { + throw new Error('Lightmap output directory must be an existing folder inside assets.'); + } + } await ensureDir(tmpDir); await ensureDir(outputDir); await outputFile(operation.inputPath, Buffer.alloc(0)); @@ -449,6 +458,16 @@ export class LightFXBakeHost implements ILightFXBakeHostService { throw new Error('Invalid LightFX bake target.'); } this.validateSceneName(options.sceneName); + if (options.outputUrl !== undefined) { + const url = options.outputUrl; + if (options.target !== 'lightmap' || typeof url !== 'string' + || (url !== 'db://assets' && !url.startsWith('db://assets/')) + || (url !== 'db://assets' && url.slice('db://assets/'.length).split('/').some(part => + !part || part === '.' || part === '..' || /[<>:"\\|?*#%]/.test(part) + || [...part].some(char => char.charCodeAt(0) < 32) || /[. ]$/.test(part)))) { + throw new Error('Invalid Lightmap output directory URL; choose an existing folder under db://assets.'); + } + } if (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 1_000 || options.timeoutMs > 3_600_000) { throw new Error('LightFX timeout must be an integer between 1000 and 3600000 milliseconds.'); } diff --git a/src/core/scene/scene-process/service/baking/lightfx/baker.ts b/src/core/scene/scene-process/service/baking/lightfx/baker.ts index 10c62149e..59dd6229a 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/baker.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/baker.ts @@ -33,12 +33,15 @@ export class LightFXCoordinator { canCancel(target: LightFXBakeTarget): boolean { return this.operation?.target === target; } - async bake(scene: Scene, target: LightFXBakeTarget, settings: LightFXSettings, timeoutMs: number): Promise { + async bake(scene: Scene, target: LightFXBakeTarget, settings: LightFXSettings, timeoutMs: number, outputUrl?: string): Promise { if (this.target) throw new Error(`A ${this.target} LightFX bake is already in progress.`); this.target = target; this.lastOperation = null; let operationId: string | undefined; try { + if (outputUrl !== undefined && (await lightFXBakeHost.queryCapabilities())?.lightmapOutputDirectory !== true) { + throw new Error('The LightFX host does not support choosing a Lightmap output directory.'); + } const exported = await new LightFXExporter().export(scene, target, settings); const transactionId = lightFXSceneOperation.hostTransactionId; ({ operationId } = await lightFXBakeHost.begin({ @@ -47,6 +50,7 @@ export class LightFXCoordinator { sceneName: scene.name, textureSources: exported.textureSources, timeoutMs, + ...(outputUrl !== undefined ? { outputUrl } : {}), })); this.operation = { operationId, transactionId, target }; this.lastOperation = this.operation; diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index 8c8b1652b..ce6ac60b9 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -28,6 +28,7 @@ export class LightmapBakeService extends BaseService impleme throw new Error('The LightFX host does not support scene transaction and immutable Lightmap asset protocol version 1.'); } return { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, + ...(host.lightmapOutputDirectory === true ? { outputDirectory: true as const } : {}), ...(host.diagnosticsVersion === 1 ? { diagnostics: await lightFXCoordinator.queryDiagnostics('lightmap') } : {}), ...(host.cancelOwnershipVersion === 1 ? { cancelVersion: 1 as const, cancellable: lightFXCoordinator.canCancel('lightmap') } : {}), busy: host.busy }; } @@ -63,7 +64,7 @@ export class LightmapBakeService extends BaseService impleme let nativeCommitted = false; this.broadcast('lightfx:bake-start', 'lightmap'); try { - output = await lightFXCoordinator.bake(scene, 'lightmap', settings, timeoutMs); + output = await lightFXCoordinator.bake(scene, 'lightmap', settings, timeoutMs, options.outputUrl); if (!output.models.length && !output.terrains.length) { throw new Error('No bakeable meshes or terrains were found.'); } diff --git a/src/core/scene/test/lightfx-asset-versions.test.ts b/src/core/scene/test/lightfx-asset-versions.test.ts index 8bdbae182..f989e4d24 100644 --- a/src/core/scene/test/lightfx-asset-versions.test.ts +++ b/src/core/scene/test/lightfx-asset-versions.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, outputFile, pathExists, readFile, remove } from 'fs-extra'; +import { ensureDir, mkdtemp, outputFile, pathExists, readFile, remove, symlink } from 'fs-extra'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -30,12 +30,12 @@ describe('Immutable Lightmap asset versions', () => { }); afterEach(async () => { await host.dispose(); await remove(root); }); - async function bake(bytes: string) { + async function bake(bytes: string, outputUrl?: string) { mockRun.mockImplementationOnce(async ({ cwd }: { cwd: string }) => { await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); await outputFile(join(cwd, 'output', 'LFX_Mesh_0000.png'), bytes); }); - const token = await host.begin(opts); + const token = await host.begin({ ...opts, outputUrl }); await host.appendInput({ ...token, chunkBase64: Buffer.from('input').toString('base64') }); const output = await host.run(token); return { token, url: output.textureUrls[0], path: assetPath(output.textureUrls[0]) }; @@ -67,12 +67,43 @@ describe('Immutable Lightmap asset versions', () => { expect(await pathExists(b.path)).toBe(false); }); + it.each(['db://assets', 'db://assets/烘焙结果/Room A'])('publishes and rolls back within the selected directory: %s', async outputUrl => { + await ensureDir(join(assetRoot, outputUrl.slice('db://assets'.length))); + const a = await bake('custom A', outputUrl); + await host.commit(a.token); + const b = await bake('custom B', outputUrl); + expect(a.url).toBe(`${outputUrl}/bake-${a.token.operationId}/LFX_Mesh_0000.png`); + expect(b.url).not.toBe(a.url); + await host.rollback(b.token); + expect(await readFile(a.path, 'utf8')).toBe('custom A'); + expect(await pathExists(b.path)).toBe(false); + expect(await pathExists(join(assetRoot, opts.sceneName))).toBe(false); + }); + + it.each(['', '/tmp/results', 'db://assets-other', 'db://assets/../outside', 'db://assets//folder', 'db://assets/folder/', 'db://assets/%2e%2e', 'db://assets/a\\b', 'db://assets/a?b', 'db://assets/a\nb', 'db://assets/a\0b'])('rejects invalid output URL before reserving or writing: %s', async outputUrl => { + await expect(host.begin({ ...opts, outputUrl })).rejects.toThrow('output directory'); + expect((await host.queryCapabilities()).busy).toBe(false); + expect(await pathExists(join(root, 'temp'))).toBe(false); + }); + + it('rejects missing folders, files and symlinks escaping assets without starting native work', async () => { + await ensureDir(assetRoot); + await outputFile(join(assetRoot, 'file'), 'keep'); + await symlink(root, join(assetRoot, 'outside'), 'dir'); + for (const name of ['missing', 'file', 'outside']) { + await expect(host.begin({ ...opts, outputUrl: `db://assets/${name}` })).rejects.toThrow(); + expect((await host.queryCapabilities()).busy).toBe(false); + } + expect(mockRun).not.toHaveBeenCalled(); + expect(await readFile(join(assetRoot, 'file'), 'utf8')).toBe('keep'); + }); + it('keeps previous assets when importing a new version fails', async () => { const a = await bake('pixels A'); await host.commit(a.token); mockAssets.refreshAsset.mockRejectedValueOnce(new Error('import unavailable')); await expect(bake('pixels B')).rejects.toThrow('import unavailable'); expect(await readFile(a.path, 'utf8')).toBe('pixels A'); - await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }); + await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }); }); }); diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts index 0666af84d..aa0a7ecb1 100644 --- a/src/core/scene/test/lightfx-bake-host.test.ts +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -73,7 +73,7 @@ describe('LightFXBakeHost', () => { } it('queries protocol and occupancy without reserving, releasing or exposing ownership', async () => { - const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }; + const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }; const busy = { ...idle, busy: true }; await expect(host.queryCapabilities()).resolves.toEqual(idle); const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); diff --git a/src/core/scene/test/lightfx-cancel-owner.test.ts b/src/core/scene/test/lightfx-cancel-owner.test.ts index cc124c033..77daf09ff 100644 --- a/src/core/scene/test/lightfx-cancel-owner.test.ts +++ b/src/core/scene/test/lightfx-cancel-owner.test.ts @@ -83,4 +83,16 @@ describe('LightFX cancellation ownership', () => { await expect(owner.cancel('light-probe')).resolves.toEqual({ cancelled: false, target: null }); expect(host.rollback).toHaveBeenCalledWith({ operationId: 'first' }); }); + + it('forwards the selected output directory only after the actual host confirms support', async () => { + const owner = new LightFXCoordinator(); + await expect(owner.bake(scene, 'lightmap', settings, 1000, 'db://assets/Lightmaps')).rejects.toThrow('output directory'); + expect(mockExport).not.toHaveBeenCalled(); + expect(host.begin).not.toHaveBeenCalled(); + expect(owner.activeTarget).toBe(null); + jest.mocked(host.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, lightmapOutputDirectory: true, busy: true }); + await owner.bake(scene, 'lightmap', settings, 1000, 'db://assets/Lightmaps'); + expect(host.begin).toHaveBeenCalledWith(expect.objectContaining({ outputUrl: 'db://assets/Lightmaps', transactionId: 'owner' })); + await owner.commit('first'); + }); }); diff --git a/src/core/scene/test/lightfx-scene-entrances.test.ts b/src/core/scene/test/lightfx-scene-entrances.test.ts index a1a4d7e05..902e8489f 100644 --- a/src/core/scene/test/lightfx-scene-entrances.test.ts +++ b/src/core/scene/test/lightfx-scene-entrances.test.ts @@ -17,6 +17,12 @@ import { lightFXBakeHost } from '../scene-process/service/baking/lightfx/host'; import { lightFXCoordinator } from '../scene-process/service/baking/lightfx/baker'; describe('LightFX service entrance ownership', () => { + it('advertises directory selection only when the actual host supports it', async () => { + for (const supported of [false, true]) { + jest.mocked(lightFXBakeHost.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, busy: false, ...(supported ? { lightmapOutputDirectory: true as const } : {}) }); + expect((await new LightmapBakeService().queryCapabilities()).outputDirectory).toBe(supported ? true : undefined); + } + }); it.each([false, true])('advertises actual Lightmap cancellation readiness (%s)', async cancellable => { jest.mocked(lightFXCoordinator.canCancel).mockReturnValueOnce(cancellable); jest.mocked(lightFXBakeHost.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, busy: true }); From afabcdb4fea9d5da563607476d452cfc2dc9d5d7 Mon Sep 17 00:00:00 2001 From: zenos Date: Thu, 10 Sep 2026 21:38:32 +0800 Subject: [PATCH 37/64] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E6=B8=85=E7=A9=BA=E4=B8=8E=E8=B4=B4=E5=9B=BE=E9=87=8D?= =?UTF-8?q?=E7=83=98=E7=84=99=E7=9A=84=E5=AE=8C=E6=95=B4=E6=92=A4=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 15 +- src/core/scene/common/lightfx-bake.ts | 2 +- src/core/scene/common/undo.ts | 10 +- .../scene/scene-process/service/editor.ts | 9 +- .../scene-process/service/light-probe-bake.ts | 17 +- .../scene-process/service/lightmap-bake.ts | 56 +------ src/core/scene/scene-process/service/undo.ts | 30 +--- .../undo/commands/lightfx-result-command.ts | 38 ----- .../service/undo/scene-undo-manager.ts | 18 +-- src/core/scene/test/editor-save-as.test.ts | 12 -- .../test/light-probe-clear-history.test.ts | 145 ------------------ .../test/lightfx-result-failures.test.ts | 82 +++++----- .../test/lightmap-result-recording.test.ts | 35 +---- 13 files changed, 78 insertions(+), 391 deletions(-) delete mode 100644 src/core/scene/scene-process/service/undo/commands/lightfx-result-command.ts delete mode 100644 src/core/scene/test/light-probe-clear-history.test.ts diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index bfb339844..cb4f63ae0 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -143,18 +143,19 @@ Scene runtime 先通过内部 `reserveSceneOperation` 取得宿主生成的事 } ``` -该操作清除当前场景全部探针的烘焙结果并通知引擎刷新,按 Creator 3.8.8 行为不生成恢复旧 SH 的 Undo 记录。清空前的普通编辑历史仍可撤销/重做,但不会带回旧 SH;清空之后新发生的 Probe Bake/编辑保留自己的历史语义。成功结果中的 `probeCount` 表示处理的探针数量。编辑录制/组合或 Undo 应用进行中时拒绝提交 Clear,恢复清空前结果。 +该操作清除当前场景全部探针的烘焙结果,通知引擎刷新,并作为一次 Undo 操作记录。成功结果中的 `probeCount` 表示处理的探针数量。 -Probe/Lightmap Bake/Clear 的 `saveScene` 默认是 `true`,显式传 `false` 时只修改内存并保留 dirty,调用方需要另行保存。Probe Clear 使用独立的非撤销修改标记:撤销普通编辑或清空历史都不能消除此标记,成功保存才消除;关闭/重新载入场景会重建会话状态。其他可撤销操作仍以完成录制后的 Undo 位置作为已保存基线。 +当前明确保留完整结果撤销修复:Probe Clear 可撤销恢复旧 SH,Lightmap 重烘焙可撤销恢复旧纹理/UV/场景标记。这沿用已有录制能力并修复快照恢复不完整的问题;按用户决定放弃后来“不恢复旧结果”的收敛。该行为与已记录的 Creator 3.8.8 实测存在差异,不宣称完全对齐。 + +Probe/Lightmap Bake/Clear 的 `saveScene` 默认是 `true`:完整成功后,当前结果作为 Undo 的已保存基线;Undo 回到旧结果会变脏,Redo 回到已保存结果恢复干净。显式传 `false` 时只修改内存并保留 dirty,调用方需要另行保存。 ### 提交与保存失败 -Bake 按「确认原生产物提交 → 应用场景结果 → 提交结果状态 → 可选保存」执行。首次 Lightmap Bake/Probe Bake 录制 Undo,已有绑定的 Lightmap 重烘焙则提交非撤销结果和 dirty 标记。Clear 无原生提交;Lightmap Clear 先完成结果录制再保存,Probe Clear 提交非撤销结果后再保存。Host 的 `committed` 只表示产物不再被取消/超时/rollback 删除,不代表 Scene 已应用或保存,也不代表整个任务成功。 +Bake 按「确认原生产物提交 → 应用场景结果 → 完成 Undo 录制 → 可选保存」执行;Clear 无原生提交,先完成结果录制再保存。Host 的 `committed` 只表示产物不再被取消/超时/rollback 删除,不代表 Scene 已应用或保存,也不代表整个任务成功。 - 原生提交拒绝或回应丢失:不应用结果、不创建结果历史、不保存场景。Host 若实际上已提交,则新版本可能成为未引用资产;保留它,不强删、不自动重新 Bake。 - 结果应用或录制前失败:恢复旧内存,不保存。已确认提交的产物仍保留,避免把不可逆的资产提交误当成可回滚事务。 - Undo 已入栈后的保存失败/回应丢失:抛出包含 `LightFX result retained` 和原始原因的错误,**保留当前结果、Undo 和产物**。保存请求可能未写盘,也可能已写盘但没有返回确认;不能通过自动恢复旧内存或删除贴图来猜测磁盘状态。调用方应刷新实际结果,允许用户检查后重新保存或 Undo,不要把失败解释为“场景未改变”。 -- Probe Clear/Lightmap 重烘焙保存失败同样保留当前结果,不自动恢复旧结果,也不承诺 Undo 恢复;错误提示要求检查后重新保存。保存前失败保持 dirty,写入已完成但回应失败则以实际保存基线为准。 - 失败时不会额外标记已保存。保存尚未写盘时结果保持 dirty;若保存已确认完成后才发生外层回应错误,内存与已保存结果相同,可以保持 clean。dirty 不是保存失败原因或磁盘写入状态的唯一证据。 - 场景保存先等待 Terrain 资产保存。已注册 Terrain 服务抛错或批量结果报告失败时,不继续保存 `.scene`、不广播保存成功、不更新保存点;后一个 Terrain 成功也不能覆盖前一个失败。已经成功写入的 Terrain 文件不做猜测性回滚,失败项保留 dirty 供重试。 @@ -386,14 +387,12 @@ LightFX 当前可能输出 Creator 历史协议版本。解析器只接受已知 - 已有另一个 LightFX 任务运行。 - 当前可见场景尚未加载完成,或同时存在多个可见的场景渲染器。 -已有绑定的 Lightmap 重烘焙不再生成恢复旧贴图的 Undo 记录,对齐 Creator 3.8.8 的重烘焙行为。此前普通编辑历史仍可执行,但本次烘焙对象的纹理/UV 和场景烘焙标记保持最新结果;内存重载后按组件 UUID 重新定位,不重新创建已经删除的对象。非撤销 dirty 标记直到保存才消除。首次 Bake 和 Lightmap Clear 的原有 Undo 语义暂保留,明确录制 MeshRenderer/Terrain 组件及 Scene 标记;不将已确认的重烘焙结论外推到未核实操作。失败仍须区分原生提交前、结果应用中和提交结果后的保存阶段,详见“提交与保存失败”。 +Bake 和 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与结果修改的 MeshRenderer/Terrain 组件(同一 Terrain 多 block 去重)及 Scene 标记,不能只录制不递归的 Scene 根节点;旧引擎缺类型的空纹理引用也会保留在快照中。成功自动保存后以该记录作为保存点;saveScene:false 不隐式保存场景。失败必须区分原生提交前、结果应用中和结果录制后保存阶段,不能对所有错误统一恢复绑定或删除资产,详见“提交与保存失败”。 -资产版本仍独立保留:未保存的新结果不能覆盖磁盘旧场景仍引用的 PNG,不能再以“重烘焙可撤销”为保留理由。首次 Bake/Clear 尚可撤销的引用保护仍有效。此前旧版本 CLI 已覆盖丢失的像素无法靠此修复找回。`deleteAssets:true` 仍会删除同 sceneName 的整个 lightmap 目录,包括所有版本;调用方不可据此假定删除可撤销或跨同名场景安全。 +绑定历史与资产版本分别保留:新版本输出不会覆盖旧 PNG,Undo 通过旧 UUID 恢复旧纹理/UV/场景标记。此前旧版本 CLI 已覆盖丢失的像素无法靠此修复找回。`deleteAssets:true` 仍会删除同 sceneName 的整个 lightmap 目录,包括所有版本;不属于可恢复绑定的保留资产验证范围,调用方不可据此假定删除可撤销或跨同名场景安全。 ## 验证范围 -以下旧轮次的 Bake/Clear Undo 记录是历史实现证据,不代表当前 Creator 对齐契约。后续收敛已改为 Probe Clear/已有绑定的 Lightmap 重烘焙不恢复旧结果;普通编辑历史、dirty 和保存失败保护保留,首次 Bake/Lightmap Clear 暂不改变。 - 当前实现已经验证: - Light Probe Bake/Clear,包含 SH 数据保存和重新加载。 diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts index 1d5e09244..6e14d486a 100644 --- a/src/core/scene/common/lightfx-bake.ts +++ b/src/core/scene/common/lightfx-bake.ts @@ -21,7 +21,7 @@ export interface ILightProbeBakeCapabilities { cancelVersion?: 1; /** Advisory readiness of this Scene's native probe operation; absent on older implementations. */ cancellable?: boolean; - /** Probe Bake/ordinary edits support SH history and multi-group reopening; Clear does not restore old SH. */ + /** SH Undo/Redo and multi-group scene reopening preserve baked results. */ resultLifecycleVersion: 1; /** Both Scene and host participate in the full Bake/Clear transaction reservation. */ sceneTransactionVersion: 1; diff --git a/src/core/scene/common/undo.ts b/src/core/scene/common/undo.ts index 2a6be580f..2c0c9d235 100644 --- a/src/core/scene/common/undo.ts +++ b/src/core/scene/common/undo.ts @@ -107,7 +107,7 @@ export interface IUndoService { /** 清空整个 undo/redo 栈,内部生命周期 API。 */ reset(): void; - /** 清空整个 undo/redo 栈,但不丢弃非撤销修改的未保存标记。 */ + /** 清空整个 undo/redo 栈。 */ clearHistory(): void; /** 当前场景有未保存变更时返回 true。 */ @@ -140,12 +140,6 @@ export interface IUndoService { */ markSaved(): void; - /** 内部:提交不可撤销的探针清空,保留普通编辑历史与未保存状态。 */ - commitLightProbeClear(): void; - - /** 内部:提交不可撤销的重烘焙,旧历史执行后重新应用最新绑定。 */ - commitLightmapRebake(restore: () => Promise): void; - /** * 当前存在进行中的录制时返回 true。 * 传入 uuid 时,只有该 uuid 被某个录制覆盖才返回 true。 @@ -177,8 +171,6 @@ export type IPublicUndoService = Omit< | 'endRecording' | 'cancelRecording' | 'hasActiveRecording' - | 'commitLightProbeClear' - | 'commitLightmapRebake' >; /** 给外部代理过滤层使用的公开 redo 命名空间。 */ diff --git a/src/core/scene/scene-process/service/editor.ts b/src/core/scene/scene-process/service/editor.ts index a57517209..0779d3d8e 100644 --- a/src/core/scene/scene-process/service/editor.ts +++ b/src/core/scene/scene-process/service/editor.ts @@ -206,7 +206,7 @@ export class EditorService extends BaseService implements IEditor } const encode = await editor.open(assetInfo, params); - this._clearUndoHistory(true); + this._clearUndoHistory(); // 设置当前打开的编辑器 this.currentEditorUuid = assetInfo.uuid; @@ -257,7 +257,7 @@ export class EditorService extends BaseService implements IEditor const result = await editor.close({ save: params.save ?? true }); if (editor === this.editorMap.get(currentEditorUuid)) { - this._clearUndoHistory(true); + this._clearUndoHistory(); this.currentEditorUuid = null; } for (const [uuid, candidate] of this.editorMap) { @@ -506,10 +506,9 @@ export class EditorService extends BaseService implements IEditor Service.Script.suspend(Promise.resolve(this.reload({}))); } - private _clearUndoHistory(resetSession = false): void { + private _clearUndoHistory(): void { try { - if (resetSession) Service.Undo?.reset(); - else Service.Undo?.clearHistory(); + Service.Undo?.clearHistory(); } catch (_e) { // UndoService may not be registered during early editor setup. } diff --git a/src/core/scene/scene-process/service/light-probe-bake.ts b/src/core/scene/scene-process/service/light-probe-bake.ts index 19ff022e4..3016fb8fe 100644 --- a/src/core/scene/scene-process/service/light-probe-bake.ts +++ b/src/core/scene/scene-process/service/light-probe-bake.ts @@ -127,26 +127,21 @@ export class LightProbeBakeService extends BaseService imple const info: any = scene.globals.lightProbeInfo; const probes: any[] = info.data?.probes ?? []; const previous = this.snapshot(probes); + const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Clear light probes' }); try { info.onProbeBakeCleared(); await Service.Engine.repaintInEditMode(); - Service.Undo.commitLightProbeClear(); + await finishSavedLightFXRecording(Service.Undo, undo, + options.saveScene !== false ? () => Service.Editor.save({}) : undefined); + return { probeCount: probes.length }; } catch (error) { + if (error instanceof LightFXResultRetainedError) throw error; + Service.Undo.cancelRecording(undo); this.restore(probes, previous); info.onProbeBakeFinished(); await Service.Engine.repaintInEditMode(); throw error; } - // A rejected save may already have written the scene. Keep the clear result - // and its dirty marker; it is no longer an operation that Undo can revert. - if (options.saveScene !== false) { - try { - await Service.Editor.save({}); - } catch (error) { - throw new Error(`LightFX result retained in the scene; save was not confirmed. Check the scene before saving again. ${this.errorMessage(error)}`); - } - } - return { probeCount: probes.length }; } cancel(): Promise { diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index ce6ac60b9..4b9437f59 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -78,37 +78,25 @@ export class LightmapBakeService extends BaseService impleme const previousBindings = this.snapshotBindings(output); const previousHighp = (scene.globals as any).bakedWithHighpLightmap; const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; - const rebake = previousBindings.some(binding => binding.texture); // Scene recordings do not recursively capture child components. // Keep the flags last, after restoring each affected result binding. const targets = [...new Set([...output.models, ...output.terrains].map(component => component.uuid)), scene.uuid]; - const undo = rebake ? undefined : Service.Undo.beginRecording(targets, { label: 'Bake lightmap' }); + const undo = Service.Undo.beginRecording(targets, { label: 'Bake lightmap' }); try { this.applyBakeResult(output, textures); (scene.globals as any).bakedWithHighpLightmap = settings.highp; (scene.globals as any).bakedWithStationaryMainLight = output.stationaryMainLight; await Service.Engine.repaintInEditMode(); - if (rebake) { - Service.Undo.commitLightmapRebake(this.retainBakeResult(scene, output)); - } else { - await finishSavedLightFXRecording(Service.Undo, undo!, - options.saveScene !== false ? () => Service.Editor.save({}) : undefined); - } + await finishSavedLightFXRecording(Service.Undo, undo, + options.saveScene !== false ? () => Service.Editor.save({}) : undefined); } catch (error) { if (error instanceof LightFXResultRetainedError) throw error; this.restoreBindings(previousBindings); (scene.globals as any).bakedWithHighpLightmap = previousHighp; (scene.globals as any).bakedWithStationaryMainLight = previousStationary; - if (undo) Service.Undo.cancelRecording(undo); + Service.Undo.cancelRecording(undo); throw error; } - if (rebake && options.saveScene !== false) { - try { - await Service.Editor.save({}); - } catch (error) { - throw new Error(`LightFX result retained in the scene; save was not confirmed. Check the scene before saving again. ${this.errorMessage(error)}`); - } - } this.broadcast('lightfx:bake-end', 'lightmap'); return { @@ -280,42 +268,6 @@ export class LightmapBakeService extends BaseService impleme ]; } - /** Resolve component identities again after Undo/reload; never resurrect removed objects. */ - private retainBakeResult(scene: Scene, output: LightFXBakeOutput): () => Promise { - const sceneUuid = scene.uuid; - const bindings = this.snapshotBindings(output).map(binding => ({ - uuid: binding.target.uuid as string, blockId: binding.blockId, texture: binding.texture, uv: binding.uv, - })); - const targets = new Set([...output.models, ...output.terrains].map(component => component.uuid)); - const highp = scene.globals.bakedWithHighpLightmap; - const stationary = scene.globals.bakedWithStationaryMainLight; - return async () => { - const current = director.getScene(); - if (!current || current.uuid !== sceneUuid) return; - const components = new Map(); - const visit = (node: any): void => { - for (const component of [...node.getComponents(MeshRenderer), ...node.getComponents(Terrain)]) { - if (targets.has(component.uuid)) components.set(component.uuid, component); - } - node.children.forEach(visit); - }; - visit(current); - const restored: LightmapBinding[] = []; - for (const binding of bindings) { - const target = components.get(binding.uuid); - if (!target) continue; - if (binding.texture && !binding.texture.isValid) { - binding.texture = await this.loadTexture(binding.texture.uuid, 60_000); - } - restored.push({ ...binding, target }); - } - this.clearBindings(this.snapshotSceneBindings(current).filter(binding => targets.has(binding.target.uuid))); - this.restoreBindings(restored); - current.globals.bakedWithHighpLightmap = highp; - current.globals.bakedWithStationaryMainLight = stationary; - }; - } - private snapshotSceneBindings(scene: Scene): LightmapBinding[] { const bindings: LightmapBinding[] = []; const visit = (node: any): void => { diff --git a/src/core/scene/scene-process/service/undo.ts b/src/core/scene/scene-process/service/undo.ts index 7a94d9487..b89a9f981 100644 --- a/src/core/scene/scene-process/service/undo.ts +++ b/src/core/scene/scene-process/service/undo.ts @@ -8,7 +8,6 @@ import type { ISnapshotAdapter } from './undo/commands/snapshot-command'; import { restoreComponentSnapshotDump, restoreNodeSnapshotDump, snapshotMapsEqual } from './undo/commands/command-utils-shared'; import dumpUtil from './dump'; import { withLightProbeTransformScenes } from './scene/light-probe-transform'; -import { LightFXResultCommand } from './undo/commands/lightfx-result-command'; interface IRecordingComponentSnapshot { uuid: string; @@ -106,22 +105,17 @@ export class UndoService extends BaseService implements IUndoServic } reset(): void { - this._clearHistory(true); + this.clearHistory(); } clearHistory(): void { - this._clearHistory(false); - } - - private _clearHistory(reset: boolean): void { const wasDirty = this._undoMgr.isDirty(); const hadUndoState = this._undoMgr.canUndo() || this._undoMgr.canRedo() || this._undoMgr.isGroupActive() || this._undoMgr.hasActiveRecording(); - if (reset) this._undoMgr.reset(); - else this._undoMgr.clearHistory(); + this._undoMgr.reset(); this._emitDirtyIfChanged(wasDirty); if (hadUndoState) { this.broadcast('undo:changed'); @@ -208,26 +202,6 @@ export class UndoService extends BaseService implements IUndoServic this._emitDirtyIfChanged(wasDirty); } - commitLightProbeClear(): void { - const scene = cc.director.getScene(); - if (!scene) throw new Error('No scene is currently open.'); - const uuid = scene.uuid; - this._commitLightFXResult('light-probe', () => { - const current = cc.director.getScene(); - if (current?.isValid && current.uuid === uuid) current.globals.lightProbeInfo.onProbeBakeCleared(); - }); - } - - commitLightmapRebake(restore: () => Promise): void { - this._commitLightFXResult('lightmap', restore); - } - - private _commitLightFXResult(target: 'light-probe' | 'lightmap', restore: () => void | Promise): void { - const wasDirty = this._undoMgr.isDirty(); - this._undoMgr.commitNonUndoableChange(command => LightFXResultCommand.protect(command, target, restore)); - this._emitDirtyIfChanged(wasDirty); - } - hasActiveRecording(uuid?: string): boolean { return this._undoMgr.hasActiveRecording(uuid); } diff --git a/src/core/scene/scene-process/service/undo/commands/lightfx-result-command.ts b/src/core/scene/scene-process/service/undo/commands/lightfx-result-command.ts deleted file mode 100644 index df341ce3a..000000000 --- a/src/core/scene/scene-process/service/undo/commands/lightfx-result-command.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { IUndoCommand, IUndoRedoResult } from '../../../../common'; - -/** Old edits still undo normally, without reverting a later non-Undo LightFX result. */ -export class LightFXResultCommand implements IUndoCommand { - readonly meta; - private readonly results = new Map<'light-probe' | 'lightmap', () => void | Promise>(); - - private constructor(private readonly command: IUndoCommand) { - this.meta = command.meta; - } - - static protect(command: IUndoCommand, target: 'light-probe' | 'lightmap', restore: () => void | Promise): LightFXResultCommand { - const protectedCommand = command instanceof LightFXResultCommand ? command : new LightFXResultCommand(command); - // Replace, rather than nest, the same result after repeated Bake/Clear. - protectedCommand.results.set(target, restore); - return protectedCommand; - } - - undo(): Promise { return this.apply('undo'); } - redo(): Promise { return this.apply('redo'); } - - private async apply(direction: 'undo' | 'redo'): Promise { - const failures: unknown[] = []; - let result: IUndoRedoResult | undefined; - try { - result = await this.command[direction](); - } catch (error) { - failures.push(error); - } - // Partially applied failed commands must not resurrect old results either. - for (const restore of this.results.values()) { - try { await restore(); } catch (error) { failures.push(error); } - } - // A missing Lightmap texture must not skip the independent SH guard. - if (failures.length) throw failures[0]; - return result!; - } -} diff --git a/src/core/scene/scene-process/service/undo/scene-undo-manager.ts b/src/core/scene/scene-process/service/undo/scene-undo-manager.ts index 2d1a191e0..301a632f2 100644 --- a/src/core/scene/scene-process/service/undo/scene-undo-manager.ts +++ b/src/core/scene/scene-process/service/undo/scene-undo-manager.ts @@ -36,7 +36,6 @@ class SceneUndoManager { private _commandArray: IUndoCommand[] = []; private _index = -1; private _lastSavedCommandId: string | null = null; - private _nonUndoableDirty = false; private _checkpointGeneration = 0; private _autoCommands: SceneUndoCommand[] = []; private _manualCommands: SceneUndoCommand[] = []; @@ -143,7 +142,6 @@ class SceneUndoManager { this._commandArray.length = 0; this._index = -1; this._lastSavedCommandId = null; - this._nonUndoableDirty = false; this._checkpointGeneration++; this._autoCommands.length = 0; this._manualCommands.length = 0; @@ -152,29 +150,17 @@ class SceneUndoManager { this._activeGroup = null; } - // Clearing history is not saving or discarding a non-Undo scene result. + // reset 的对外别名(IUndoService 同时暴露 reset/clearHistory)。 clearHistory(): void { - const nonUndoableDirty = this._nonUndoableDirty; this.reset(); - this._nonUndoableDirty = nonUndoableDirty; } markSaved(): void { this._lastSavedCommandId = this._currentCommandId(); - this._nonUndoableDirty = false; } isDirty(): boolean { - return this._nonUndoableDirty || this._lastSavedCommandId !== this._currentCommandId(); - } - - /** Keep existing edits, but prevent their snapshots from reverting a non-Undo result. */ - commitNonUndoableChange(protect: (command: IUndoCommand) => IUndoCommand): void { - if (this.hasActiveRecording() || this.isGroupActive() || this.isApplying()) { - throw new Error('Cannot commit a non-Undo result while an edit is active.'); - } - this._commandArray = this._commandArray.map(protect); - this._nonUndoableDirty = true; + return this._lastSavedCommandId !== this._currentCommandId(); } createCheckpoint(): IUndoCheckpoint { diff --git a/src/core/scene/test/editor-save-as.test.ts b/src/core/scene/test/editor-save-as.test.ts index 1a51f0157..84e05e1b7 100644 --- a/src/core/scene/test/editor-save-as.test.ts +++ b/src/core/scene/test/editor-save-as.test.ts @@ -22,7 +22,6 @@ jest.mock('../scene-process/service/core', () => ({ Service: { Undo: { clearHistory: jest.fn(), - reset: jest.fn(), markSaved: jest.fn(), }, }, @@ -52,17 +51,6 @@ describe('EditorService Save As', () => { globalEventEmitter.removeAllListeners(); }); - it('distinguishes a new scene session from history reset during in-memory reload', () => { - const { Service } = require('../scene-process/service/core'); - Service.Undo.reset.mockClear(); - Service.Undo.clearHistory.mockClear(); - editorService._clearUndoHistory(true); - expect(Service.Undo.reset).toHaveBeenCalledTimes(1); - expect(Service.Undo.clearHistory).not.toHaveBeenCalled(); - editorService._clearUndoHistory(); - expect(Service.Undo.clearHistory).toHaveBeenCalledTimes(1); - }); - it('requires Save As for a target other than the existing source asset', async () => { const sourceUuid = 'source-uuid'; const target = { uuid: 'target-uuid', url: 'db://assets/copied.scene', type: 'scene' }; diff --git a/src/core/scene/test/light-probe-clear-history.test.ts b/src/core/scene/test/light-probe-clear-history.test.ts deleted file mode 100644 index e2bc773e4..000000000 --- a/src/core/scene/test/light-probe-clear-history.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { Scene } from 'cc'; -import type { IUndoCommand } from '../common'; -import { SceneUndoManager } from '../scene-process/service/undo/scene-undo-manager'; -import { LightFXResultCommand } from '../scene-process/service/undo/commands/lightfx-result-command'; - -function protectClear(command: IUndoCommand, uuid: string, getScene: () => Scene) { - return LightFXResultCommand.protect(command, 'light-probe', () => { - const scene = getScene(); - if (scene.isValid && scene.uuid === uuid) scene.globals.lightProbeInfo.onProbeBakeCleared(); - }); -} - -function fixture() { - const state = { gi: 1, coefficients: [1, 2, 3] }; - const scene = { uuid: 'scene', isValid: true, globals: { lightProbeInfo: { onProbeBakeCleared() { state.coefficients = []; } } } }; - const manager = new SceneUndoManager({ snapshotAdapter: { - capture: () => new Map([['scene', structuredClone(state)]]), - equals: (a, b) => JSON.stringify([...a]) === JSON.stringify([...b]), - apply: data => { Object.assign(state, structuredClone(data.get('scene'))); return { success: true }; }, - } }); - const edit = async (gi: number, coefficients = state.coefficients) => { - const id = manager.beginRecording(['scene']); - Object.assign(state, { gi, coefficients }); - await manager.endRecording(id); - }; - const clear = () => { - manager.commitNonUndoableChange(command => protectClear(command, scene.uuid, () => scene as unknown as Scene)); - scene.globals.lightProbeInfo.onProbeBakeCleared(); - }; - return { state, scene, manager, edit, clear }; -} - -describe('non-Undo probe Clear', () => { - it('is dirty without adding history, and stays dirty until saved', async () => { - const f = fixture(); - f.clear(); - await f.manager.undo(); - expect([f.state.coefficients, f.manager.isDirty(), f.manager.canUndo()]).toEqual([[], true, false]); - f.manager.clearHistory(); - expect(f.manager.isDirty()).toBe(true); - f.manager.markSaved(); - expect(f.manager.isDirty()).toBe(false); - f.clear(); - f.manager.reset(); - expect(f.manager.isDirty()).toBe(false); - }); - - it('keeps ordinary Undo/Redo and never restores pre-Clear SH even after saving', async () => { - const f = fixture(); - await f.edit(2); - f.clear(); - await f.manager.undo(); - expect([f.state, f.manager.isDirty()]).toEqual([{ gi: 1, coefficients: [] }, true]); - f.manager.markSaved(); - await f.manager.redo(); - expect([f.state, f.manager.isDirty()]).toEqual([{ gi: 2, coefficients: [] }, true]); - await f.manager.undo(); - expect([f.state, f.manager.isDirty()]).toEqual([{ gi: 1, coefficients: [] }, false]); - }); - - it('protects the existing redo branch as well as the undo branch', async () => { - const f = fixture(); - await f.edit(2); - await f.manager.undo(); - f.clear(); - await f.manager.redo(); - expect(f.state).toEqual({ gi: 2, coefficients: [] }); - }); - - it('does not change the result lifecycle of a later Probe Bake', async () => { - const f = fixture(); - await f.edit(2); - f.clear(); - await f.edit(3, [9]); - await f.manager.undo(); - await f.manager.undo(); - expect(f.state).toEqual({ gi: 1, coefficients: [] }); - await f.manager.redo(); - expect(f.state).toEqual({ gi: 2, coefficients: [] }); - await f.manager.redo(); - expect(f.state).toEqual({ gi: 3, coefficients: [9] }); - }); - - it('handles repeated Clear and composite histories without adding wrappers repeatedly', async () => { - const f = fixture(); - const group = f.manager.beginGroup(); - await f.edit(2); - await f.edit(3); - f.manager.endGroup(group); - f.clear(); - const protectedCommand = f.manager.getHistoryForTesting()[0]; - f.clear(); - expect(f.manager.getHistoryForTesting()[0]).toBe(protectedCommand); - await f.manager.undo(); - expect(f.state).toEqual({ gi: 1, coefficients: [] }); - await f.manager.redo(); - expect(f.state).toEqual({ gi: 3, coefficients: [] }); - }); - - it('rejects active edits without changing history or dirty state', () => { - const f = fixture(); - const id = f.manager.beginRecording(['scene']); - expect(() => f.clear()).toThrow('edit is active'); - f.manager.cancelRecording(id); - const group = f.manager.beginGroup(); - expect(() => f.clear()).toThrow('edit is active'); - f.manager.cancelGroup(group); - expect([f.state.coefficients, f.manager.isDirty(), f.manager.canUndo()]).toEqual([[1, 2, 3], false, false]); - }); - - it('protects a reloaded instance of the same scene, but never a different scene', async () => { - const f = fixture(); - await f.edit(2); - const clear = jest.fn(); - let current = { ...f.scene, globals: { lightProbeInfo: { onProbeBakeCleared: clear } } }; - const command = protectClear(f.manager.getHistoryForTesting()[0], f.scene.uuid, () => current as unknown as Scene); - await command.undo(); - expect(clear).toHaveBeenCalledTimes(1); - current = { ...current, uuid: 'other-scene' }; - await command.redo(); - expect(clear).toHaveBeenCalledTimes(1); - }); - - it('clears SH even when an old command partially applies and then fails', async () => { - const f = fixture(); - const command = protectClear({ - meta: { id: 'failed-edit', label: 'Failed edit', type: 'test', scope: {}, timestamp: 0 }, - async undo() { f.state.coefficients = [9]; throw new Error('partial failure'); }, - async redo() { return { success: true }; }, - }, f.scene.uuid, () => f.scene as unknown as Scene); - await expect(command.undo()).rejects.toThrow('partial failure'); - expect(f.state.coefficients).toEqual([]); - }); - - it('still protects cleared SH when an independent Lightmap restore fails', async () => { - const f = fixture(); - await f.edit(2); - const command = LightFXResultCommand.protect(f.manager.getHistoryForTesting()[0], 'lightmap', async () => { - throw new Error('texture unavailable'); - }); - protectClear(command, f.scene.uuid, () => f.scene as unknown as Scene); - await expect(command.undo()).rejects.toThrow('texture unavailable'); - expect(f.state.coefficients).toEqual([]); - }); -}); diff --git a/src/core/scene/test/lightfx-result-failures.test.ts b/src/core/scene/test/lightfx-result-failures.test.ts index efd26521d..c912bdcfe 100644 --- a/src/core/scene/test/lightfx-result-failures.test.ts +++ b/src/core/scene/test/lightfx-result-failures.test.ts @@ -10,7 +10,7 @@ class MockVec3 { } const mockBake = jest.fn(), mockCommit = jest.fn(), mockRollback = jest.fn(); const mockSave = jest.fn(), mockRepaint = jest.fn(); -const mockUndo = { beginRecording: jest.fn(), endRecording: jest.fn(), cancelRecording: jest.fn(), createCheckpoint: jest.fn(), commitLightProbeClear: jest.fn(), commitLightmapRebake: jest.fn() }; +const mockUndo = { beginRecording: jest.fn(), endRecording: jest.fn(), cancelRecording: jest.fn(), createCheckpoint: jest.fn() }; jest.mock('cc', () => ({ director: { getScene: mockGetScene }, MeshRenderer: mockMeshRenderer, Terrain: mockTerrain, Vec3: MockVec3, SH: { getBasisCount: () => 9 } })); jest.mock('../scene-process/service/core', () => ({ @@ -30,11 +30,10 @@ jest.mock('../scene-process/rpc', () => ({ Rpc: { getInstance: jest.fn() } })); import { LightmapBakeService } from '../scene-process/service/lightmap-bake'; import { LightProbeBakeService } from '../scene-process/service/light-probe-bake'; import { SceneUndoManager } from '../scene-process/service/undo/scene-undo-manager'; -import { LightFXResultCommand } from '../scene-process/service/undo/commands/lightfx-result-command'; function fixture(target: 'probe' | 'lightmap') { const events: string[] = []; - const oldTexture = { uuid: 'old', isValid: true }, texture = { uuid: 'new', isValid: true }; + const oldTexture = { uuid: 'old' }, texture = { uuid: 'new' }; let assets = ['old', 'new']; let committed = false; const model = { uuid: 'mesh', node: {}, bakeSettings: { texture: oldTexture as { uuid: string } | null, @@ -45,7 +44,7 @@ function fixture(target: 'probe' | 'lightmap') { } }; const probes = Array.from({ length: 4 }, (_, x) => ({ position: new MockVec3(x), normal: new MockVec3(), coefficients: [new MockVec3(1)] })); const info = { data: { probes }, giScale: 1, onProbeBakeFinished() {}, onProbeBakeCleared() { probes.forEach(p => { p.coefficients = []; }); } }; - const scene = { uuid: 'scene', name: 'test', isValid: true, globals: { lightProbeInfo: info, bakedWithHighpLightmap: false, bakedWithStationaryMainLight: false }, + const scene = { uuid: 'scene', name: 'test', globals: { lightProbeInfo: info, bakedWithHighpLightmap: false, bakedWithStationaryMainLight: false }, children: [], getComponents: (type: unknown) => type === mockMeshRenderer ? [model] : [], }; const read = () => ({ texture: model.bakeSettings.texture?.uuid ?? null, uv: model.bakeSettings.uvParam.clone(), @@ -69,12 +68,6 @@ function fixture(target: 'probe' | 'lightmap') { mockUndo.endRecording.mockImplementation(async id => { events.push('record'); await manager.endRecording(id); }); mockUndo.cancelRecording.mockImplementation(id => manager.cancelRecording(id)); mockUndo.createCheckpoint.mockImplementation(() => manager.createCheckpoint()); - mockUndo.commitLightProbeClear.mockImplementation(() => manager.commitNonUndoableChange(command => - LightFXResultCommand.protect(command, 'light-probe', () => info.onProbeBakeCleared()))); - mockUndo.commitLightmapRebake.mockImplementation(restore => { - events.push('retain'); - manager.commitNonUndoableChange(command => LightFXResultCommand.protect(command, 'lightmap', restore)); - }); const save = async () => { events.push('save'); disk = read(); manager.markSaved(); }; mockSave.mockImplementation(save); mockCommit.mockImplementation(async () => { events.push('commit'); committed = true; }); @@ -87,8 +80,7 @@ function fixture(target: 'probe' | 'lightmap') { const service = target === 'probe' ? new LightProbeBakeService() : new LightmapBakeService(); jest.spyOn(service as any, 'querySceneUrl').mockResolvedValue('db://assets/test.scene'); if (service instanceof LightmapBakeService) jest.spyOn(service as any, 'loadOutputTextures').mockResolvedValue(new Map([['mesh:0', texture]])); - return { service, manager, read, disk: () => disk, assets: () => assets, events, save, model, scene, - editGi: async () => { const id = manager.beginRecording(['scene']); info.giScale = 7; await manager.endRecording(id); }, + return { service, manager, read, disk: () => disk, assets: () => assets, events, save, commit: async () => { committed = true; }, bake: () => service.bake({ giScale: 2, highp: true }), old: read() }; } @@ -97,7 +89,41 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t it('confirms asset retention before recording or saving', async () => { const f = fixture(target); await f.bake(); - expect({ events: f.events, disk: f.disk(), dirty: f.manager.isDirty() }).toEqual({ events: ['commit', target === 'probe' ? 'record' : 'retain', 'save'], disk: f.read(), dirty: false }); + expect({ events: f.events, disk: f.disk(), dirty: f.manager.isDirty() }).toEqual({ events: ['commit', 'record', 'save'], disk: f.read(), dirty: false }); + }); + + it.each([false, true])('restores complete results in edit → rebake → clear history (save=%s)', async saveScene => { + const f = fixture(target); + const scene = mockGetScene(); + const edit = f.manager.beginRecording(['scene']); + scene.globals.lightProbeInfo.giScale = 1.5; + await f.manager.endRecording(edit); + const edited = f.read(); + + await f.service.bake({ giScale: 2, highp: true, saveScene }); + const baked = f.read(); + await f.service.clearBake({ saveScene }); + const cleared = f.read(); + expect(cleared).not.toEqual(baked); + expect(f.manager.isDirty()).toBe(!saveScene); + + await f.manager.undo(); + expect(f.read()).toEqual(baked); + await f.manager.undo(); + expect(f.read()).toEqual(edited); + await f.manager.undo(); + expect(f.read()).toEqual(f.old); + expect(f.manager.canUndo()).toBe(false); + + await f.manager.redo(); + expect(f.read()).toEqual(edited); + await f.manager.redo(); + expect(f.read()).toEqual(baked); + await f.manager.redo(); + expect(f.read()).toEqual(cleared); + expect(f.manager.canRedo()).toBe(false); + expect(f.manager.isDirty()).toBe(!saveScene); + expect(f.assets()).toEqual(['old', 'new']); }); it.each([false, true])('does not mutate scene, disk or history on commit failure (host committed=%s)', async committed => { @@ -111,7 +137,7 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t expect(mockUndo.beginRecording).not.toHaveBeenCalled(); }); - it.each(['bake', 'clear'] as const)('retains %s after pre-write save failure with its operation-specific Undo contract', async action => { + it.each(['bake', 'clear'] as const)('retains %s after pre-write save failure, supports Undo/Redo and retry', async action => { const f = fixture(target); mockSave.mockRejectedValueOnce(new Error('disk unavailable')); await expect(action === 'bake' ? f.bake() : f.service.clearBake()).rejects.toThrow('result retained'); @@ -121,7 +147,7 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); expect(mockRollback).not.toHaveBeenCalled(); await f.manager.undo(); - expect(f.read()).toEqual((target === 'probe' && action === 'clear') || (target === 'lightmap' && action === 'bake') ? result : f.old); + expect(f.read()).toEqual(f.old); await f.manager.redo(); expect(f.read()).toEqual(result); await f.save(); @@ -137,9 +163,8 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); expect(mockRollback).not.toHaveBeenCalled(); await f.manager.undo(); - const nonUndoable = (target === 'probe' && action === 'clear') || (target === 'lightmap' && action === 'bake'); - expect(f.read()).toEqual(nonUndoable ? result : f.old); - expect(f.manager.isDirty()).toBe(!nonUndoable); + expect(f.read()).toEqual(f.old); + expect(f.manager.isDirty()).toBe(true); await f.manager.redo(); expect({ memory: f.read(), disk: f.disk(), assets: f.assets(), dirty: f.manager.isDirty() }).toEqual({ memory: result, disk: result, assets: ['old', 'new'], dirty: false, @@ -157,7 +182,7 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t expect(mockRollback).not.toHaveBeenCalled(); }); - it('restores Clear when application fails before committing the result', async () => { + it('restores Clear when application fails before recording the result', async () => { const f = fixture(target); mockRepaint.mockRejectedValueOnce(new Error('clear repaint failed')); await expect(f.service.clearBake({ saveScene: false })).rejects.toThrow('clear repaint failed'); @@ -166,24 +191,11 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t }); }); -describe('Lightmap rebake history', () => { +describe('Lightmap first bake history', () => { beforeEach(() => jest.resetAllMocks()); - it('keeps new texture, UV and flags while older GI edits still undo and redo', async () => { - const f = fixture('lightmap'); - await f.editGi(); - await f.service.bake({ saveScene: false, highp: true }); - const latest = f.read(); - await f.manager.undo(); - expect(f.read()).toEqual({ ...latest, giScale: 1 }); - expect(f.manager.isDirty()).toBe(true); - await f.manager.redo(); - expect(f.read()).toEqual(latest); - await f.save(); - expect(f.manager.isDirty()).toBe(false); - }); - it('leaves first Bake and later Lightmap Clear undoable', async () => { + it('restores an empty binding on Undo and preserves later rebake and Clear records', async () => { const f = fixture('lightmap'); - f.model.bakeSettings.texture = null; + mockGetScene().getComponents(mockMeshRenderer)[0].bakeSettings.texture = null; await f.service.bake({ saveScene: false }); expect(mockUndo.beginRecording).toHaveBeenCalled(); await f.manager.undo(); diff --git a/src/core/scene/test/lightmap-result-recording.test.ts b/src/core/scene/test/lightmap-result-recording.test.ts index 15e1ff6ec..bd295ce94 100644 --- a/src/core/scene/test/lightmap-result-recording.test.ts +++ b/src/core/scene/test/lightmap-result-recording.test.ts @@ -10,7 +10,6 @@ const mockUndo = { cancelRecording: jest.fn(), createCheckpoint: jest.fn(() => ({ commandId: 'recording', generation: 1 })), markSaved: jest.fn(), - commitLightmapRebake: jest.fn(), }; const mockSave = jest.fn(async () => undefined); jest.mock('cc', () => ({ director: { getScene: mockGetScene }, MeshRenderer: mockMeshRenderer, Terrain: mockTerrain })); @@ -40,7 +39,7 @@ function fixture() { mockGetScene.mockReturnValue(scene); const service = new LightmapBakeService(); jest.spyOn(service as any, 'querySceneUrl').mockResolvedValue('db://assets/test.scene'); - const texture = { uuid: 'new-texture', isValid: true }; + const texture = { uuid: 'new-texture' }; jest.spyOn(service as any, 'loadOutputTextures').mockResolvedValue(new Map([['mesh:0', texture], ['terrain:0', texture]])); mockBake.mockResolvedValue({ models: [model], terrains: [terrain], operationId: 'operation', stationaryMainLight: true, textureUrls: [], result: { meshes: [{ id: 0, index: 0, offset: [0.1, 0.2], scale: [0.3, 0.4] }], @@ -51,14 +50,13 @@ function fixture() { describe('Lightmap result recording targets', () => { beforeEach(() => jest.clearAllMocks()); - it.each([false, true])('retains Mesh and Terrain rebake results without adding Undo (save=%s)', async saveScene => { + it.each([false, true])('records Mesh and Terrain components before scene flags for Bake (save=%s)', async saveScene => { const f = fixture(); await f.service.bake({ saveScene }); - expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + expect(mockUndo.beginRecording).toHaveBeenCalledWith(['mesh', 'terrain', 'scene'], { label: 'Bake lightmap' }); expect(f.model._updateLightmap).toHaveBeenCalledWith(f.texture, 0.1, 0.2, 0.3, 0.4); expect(f.terrain._updateLightmap).toHaveBeenCalledWith(1, f.texture, 0.5, 0.6, 0.7, 0.8); - expect(mockUndo.endRecording).not.toHaveBeenCalled(); - expect(mockUndo.commitLightmapRebake).toHaveBeenCalledWith(expect.any(Function)); + expect(mockUndo.endRecording).toHaveBeenCalledWith('recording'); expect(mockCommit).toHaveBeenCalledWith('operation'); expect(mockSave).toHaveBeenCalledTimes(saveScene ? 1 : 0); expect(mockUndo.markSaved).not.toHaveBeenCalled(); @@ -84,29 +82,4 @@ describe('Lightmap result recording targets', () => { expect(f.model._updateLightmap).toHaveBeenLastCalledWith(null, 0, 0, 0, 0); expect(f.terrain._updateLightmap).toHaveBeenLastCalledWith(1, null, 0, 0, 0, 0); }); - - it('resolves reloaded Terrain blocks by identity, clears obsolete blocks and leaves unrelated objects alone', async () => { - const f = fixture(); - f.terrain._resetLightmap.mockImplementation(() => { f.terrain._lightmapInfos = []; }); - f.terrain._updateLightmap.mockImplementation((blockId, texture, UOff, VOff, UScale, VScale) => { - f.terrain._lightmapInfos[blockId] = { texture, UOff, VOff, UScale, VScale }; - }); - await f.service.bake({ saveScene: false, highp: true }); - const oldScene = mockGetScene(); - const replacement = { ...f.terrain, _lightmapInfos: [ - { texture: f.oldTexture, UOff: 1, VOff: 2, UScale: 3, VScale: 4 }, - { texture: f.oldTexture, UOff: 5, VOff: 6, UScale: 7, VScale: 8 }, - ], _updateLightmap: jest.fn() }; - const unrelated = { ...f.model, uuid: 'unrelated', _updateLightmap: jest.fn() }; - const current = { ...oldScene, globals: { bakedWithHighpLightmap: false, bakedWithStationaryMainLight: false }, - getComponents: (type: unknown) => type === mockMeshRenderer ? [unrelated] : type === mockTerrain ? [replacement] : [], - }; - mockGetScene.mockReturnValue(current); - await mockUndo.commitLightmapRebake.mock.calls[0][0](); - expect(replacement._updateLightmap.mock.calls).toEqual([ - [0, null, 0, 0, 0, 0], [1, null, 0, 0, 0, 0], [1, f.texture, 0.5, 0.6, 0.7, 0.8], - ]); - expect(unrelated._updateLightmap).not.toHaveBeenCalled(); - expect(current.globals).toEqual({ bakedWithHighpLightmap: true, bakedWithStationaryMainLight: true }); - }); }); From eabafbe9acb927e1034924ce6cdd403871749d56 Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 10:32:49 +0800 Subject: [PATCH 38/64] =?UTF-8?q?feat(scene):=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E5=85=89=E7=85=A7=E7=83=98=E7=84=99=E6=95=B0=E5=80=BC=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 4 ++-- src/core/scene/common/lightfx-host.ts | 4 +++- .../scene/main-process/lightfx-bake-host.ts | 15 ++++++++++++++- src/core/scene/test/lightfx-bake-host.test.ts | 19 ++++++++++++++++--- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index cb4f63ae0..1b1f354f7 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -45,9 +45,9 @@ const capabilities = await cli.Scene.LightmapBake.queryCapabilities(); ### 原生诊断 -Probe/Lightmap 的 `queryCapabilities()` 和成功 Bake 结果可带 `diagnostics`:`{ version: 1, stage, logs, progress? }`。Scene 只返回本运行实例、对应烘焙类型的当前或最近原生操作,内部 Host 查询校验 operation ID、target 与 transaction ID;不会返回其他场景的日志。没有可用诊断或查询失败时字段可缺省,集成方应降级显示,不能因此把烘焙成功改为失败。 +Probe/Lightmap 的 `queryCapabilities()` 和成功 Bake 结果可带 `diagnostics`:`{ version: 1, stage, logs, progress?, rate? }`。Scene 只返回本运行实例、对应烘焙类型的当前或最近原生操作,内部 Host 查询校验 operation ID、target 与 transaction ID;不会返回其他场景的日志。没有可用诊断或查询失败时字段可缺省,集成方应降级显示,不能因此把烘焙成功改为失败。 -Host 最多记住 32 个操作;每个操作保留最近 128 条日志,每条与进度文本上限为 2048 字符,隐藏该操作工作目录和目标资产目录的绝对路径。`progress` 保留 LightFX 原始文本(例如 `Build lighting 25%`),不是统一数值百分比;`stage` 是最近采样的原生阶段,不代替上层 Scene 的成功/取消/恢复状态。进程重启后诊断不保留,不提供持久任务身份或失联事务恢复。 +Host 最多记住 32 个操作;每个操作保留最近 128 条日志,每条与进度文本上限为 2048 字符,隐藏该操作工作目录和目标资产目录的绝对路径。`progress` 保留 LightFX 原始文本(例如 `Build lighting 25%`);仅当专用 Progress 事件严格匹配该已验证格式且数值位于 0–100 时,另提供 `rate`。未知格式不得从日志或任意数字推断百分比。`stage` 是最近采样的原生阶段,不代替上层 Scene 的成功/取消/恢复状态。进程重启后诊断不保留,不提供持久任务身份或失联事务恢复。 ## MCP 工具 diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts index e738379a6..e7e058c22 100644 --- a/src/core/scene/common/lightfx-host.ts +++ b/src/core/scene/common/lightfx-host.ts @@ -24,12 +24,14 @@ export interface ILightFXHostCapabilities { busy: boolean; } -/** Native diagnostic text is informational, never a progress percentage or an instruction. */ +/** Native diagnostic data is informational and never controls the bake transaction. */ export interface ILightFXDiagnostics { version: 1; stage: string; logs: string[]; progress?: string; + /** Verified percentage from the native Progress channel; absent for unknown payload formats. */ + rate?: number; } /** JSON-safe reference to a texture needed by a LightFX input file. */ diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index 7d2fba1b4..4d02a4f39 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -73,6 +73,15 @@ const MAX_INPUT_CHUNK_BASE64_LENGTH = 1024 * 1024; const MAX_INPUT_BYTES = 1024 * 1024 * 1024; const MAX_TEXTURE_SOURCES = 10_000; +/** Reads only the native percentage format observed on the dedicated Progress channel. */ +export function parseLightFXProgressRate(value: unknown): number | undefined { + if (typeof value !== 'string') { return undefined; } + const match = /^Build lighting (?\d{1,3}(?:\.\d+)?)%$/.exec(value.trim()); + if (!match?.groups) { return undefined; } + const rate = Number(match.groups.rate); + return Number.isFinite(rate) && rate >= 0 && rate <= 100 ? rate : undefined; +} + /** * Executes every Node-only part of a LightFX bake on behalf of either a Scene worker or a browser * Scene Webview. Only one operation can exist at a time, including the apply/save transaction gap. @@ -310,7 +319,11 @@ export class LightFXBakeHost implements ILightFXBakeHostService { }, onProgress: progress => { if (this.operation !== operation || operation.terminalState) { return; } - this.diagnostics.get(operation.id)!.value.progress = this.diagnosticText(operation, progress); + const diagnostic = this.diagnostics.get(operation.id)!.value; + diagnostic.progress = this.diagnosticText(operation, progress); + const rate = parseLightFXProgressRate(progress); + if (rate === undefined) { delete diagnostic.rate; } + else { diagnostic.rate = rate; } }, }); this.throwIfTerminated(operation); diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts index aa0a7ecb1..76de38707 100644 --- a/src/core/scene/test/lightfx-bake-host.test.ts +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -28,7 +28,7 @@ jest.mock('../main-process/lightfx/output', () => ({ decodeLightFXOutput: jest.fn(() => mockDecodedResult), })); -import { LightFXBakeHost } from '../main-process/lightfx-bake-host'; +import { LightFXBakeHost, parseLightFXProgressRate } from '../main-process/lightfx-bake-host'; describe('LightFXBakeHost', () => { let root: string; @@ -72,6 +72,18 @@ describe('LightFXBakeHost', () => { return operationId; } + it('extracts only verified percentages from the native Progress channel', () => { + expect([ + parseLightFXProgressRate('Build lighting 0%'), + parseLightFXProgressRate('Build lighting 25%\n'), + parseLightFXProgressRate('Build lighting 99.5%'), + parseLightFXProgressRate('Build lighting 100%'), + ]).toEqual([0, 25, 99.5, 100]); + for (const value of ['[2,400]', 'Build lighting 101%', 'Build lighting -1%', 'Other stage 25%', '25%', { rate: 25 }]) { + expect(parseLightFXProgressRate(value)).toBeUndefined(); + } + }); + it('queries protocol and occupancy without reserving, releasing or exposing ownership', async () => { const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }; const busy = { ...idle, busy: true }; @@ -99,6 +111,7 @@ describe('LightFXBakeHost', () => { lateLog = onLog; for (let index = 0; index < 150; index++) { onLog(`line ${index}`); } onProgress({ native: [1, 4], file: cwd }); + onProgress('Build lighting 25%\n'); await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); }); const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); @@ -107,8 +120,8 @@ describe('LightFXBakeHost', () => { await host.appendInput({ operationId, chunkBase64: Buffer.from('input').toString('base64') }); await host.run({ operationId }); const diagnostic = (await host.queryDiagnostics(owner))!; - expect([diagnostic.stage, diagnostic.logs.length, diagnostic.logs[0], diagnostic.logs.at(-1), diagnostic.progress]) - .toEqual(['awaiting-commit', 128, 'line 22', 'line 149', '{"native":[1,4],"file":""}']); + expect([diagnostic.stage, diagnostic.logs.length, diagnostic.logs[0], diagnostic.logs.at(-1), diagnostic.progress, diagnostic.rate]) + .toEqual(['awaiting-commit', 128, 'line 22', 'line 149', 'Build lighting 25%\n', 25]); diagnostic.logs.length = 0; await expect(host.queryDiagnostics({ ...owner, target: 'lightmap' })).resolves.toBeUndefined(); await expect(host.queryDiagnostics({ ...owner, transactionId: undefined })).resolves.toBeUndefined(); From c97fc8c4755e85b6f667197daa0b69b919e3e911 Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 10:53:35 +0800 Subject: [PATCH 39/64] =?UTF-8?q?fix(scene):=20=E9=87=8D=E7=AE=97=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E6=B3=95=E7=BA=BF=E5=89=8D=E6=B8=85=E9=99=A4=E6=97=A7?= =?UTF-8?q?=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scene/scene-process/engine-bootstrap.ts | 2 + .../scene-process/light-probe-normal-reset.ts | 44 ++++++++++++++++++ .../test/light-probe-normal-reset.test.ts | 46 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 src/core/scene/scene-process/light-probe-normal-reset.ts create mode 100644 src/core/scene/test/light-probe-normal-reset.test.ts diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index 2cf9417bd..79312fd35 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -9,6 +9,7 @@ import { messageManager } from './service/message'; import { initLocalI18n } from './i18n'; import { CUSTOM_PIPELINE_MODULE } from '../../engine/graphics-config'; import { fetchSceneEditorSettings, syncSceneEditorBundles } from './scene-editor-assets'; +import { installLightProbeNormalReset } from './light-probe-normal-reset'; import './service'; @@ -122,6 +123,7 @@ export async function startup(options: { cc.physics.selector.runInEditor = true; await cc.game.init(config); + installLightProbeNormalReset(cc); // scene 进程运行在编辑器内嵌视图中,屏幕方向无意义;项目设置默认 'auto' 会让 // screenAdapter.orientation 停在 Orientation.AUTO(13),引擎 resize 时对未映射方向打 DEBUG 告警,这里固定为竖屏。 cc.view.setOrientation(cc.macro.ORIENTATION_PORTRAIT); diff --git a/src/core/scene/scene-process/light-probe-normal-reset.ts b/src/core/scene/scene-process/light-probe-normal-reset.ts new file mode 100644 index 000000000..41f145b79 --- /dev/null +++ b/src/core/scene/scene-process/light-probe-normal-reset.ts @@ -0,0 +1,44 @@ +type ProbeNormal = { + set(x: number, y: number, z: number): unknown; +}; + +type Probe = { + normal?: ProbeNormal; +}; + +type LightProbesData = { + probes?: Probe[]; +}; + +type UpdateTetrahedrons = (this: LightProbesData, ...args: unknown[]) => unknown; + +const NORMAL_RESET_PATCH = Symbol('cocos-cli-light-probe-normal-reset'); + +type PatchedUpdateTetrahedrons = UpdateTetrahedrons & { + [NORMAL_RESET_PATCH]?: boolean; +}; + +/** + * Cocos 4.0 accumulates convex-hull normals into the serialized Vertex.normal + * values on every tetrahedron rebuild. Clear the derived values at the CLI + * adapter boundary so edits and scene reloads always recompute from geometry. + */ +export function installLightProbeNormalReset(engine: unknown): boolean { + const prototype = (engine as { + internal?: { LightProbesData?: { prototype?: { updateTetrahedrons?: PatchedUpdateTetrahedrons } } }; + })?.internal?.LightProbesData?.prototype; + const original = prototype?.updateTetrahedrons; + if (!prototype || typeof original !== 'function' || original[NORMAL_RESET_PATCH]) { + return false; + } + + const patched: PatchedUpdateTetrahedrons = function (...args: unknown[]): unknown { + for (const probe of this.probes ?? []) { + probe.normal?.set(0, 0, 0); + } + return original.apply(this, args); + }; + Object.defineProperty(patched, NORMAL_RESET_PATCH, { value: true }); + prototype.updateTetrahedrons = patched; + return true; +} diff --git a/src/core/scene/test/light-probe-normal-reset.test.ts b/src/core/scene/test/light-probe-normal-reset.test.ts new file mode 100644 index 000000000..2bc7b0324 --- /dev/null +++ b/src/core/scene/test/light-probe-normal-reset.test.ts @@ -0,0 +1,46 @@ +import { installLightProbeNormalReset } from '../scene-process/light-probe-normal-reset'; + +function createEngine(updateTetrahedrons: (...args: unknown[]) => unknown) { + return { + internal: { + LightProbesData: { + prototype: { updateTetrahedrons }, + }, + }, + }; +} + +describe('light probe normal reset', () => { + it('clears restored normals before rebuilding tetrahedrons', () => { + const observed: number[][] = []; + const original = jest.fn(function (this: { probes: Array<{ normal: { values: number[] } }> }, token: string) { + observed.push(...this.probes.map(probe => [...probe.normal.values])); + return token; + }); + const engine = createEngine(original as unknown as (...args: unknown[]) => unknown); + const data = { + probes: [ + { normal: { values: [1, 2, 3], set(x: number, y: number, z: number) { this.values = [x, y, z]; } } }, + { normal: { values: [-1, 4, 8], set(x: number, y: number, z: number) { this.values = [x, y, z]; } } }, + ], + }; + + expect(installLightProbeNormalReset(engine)).toBe(true); + expect(engine.internal.LightProbesData.prototype.updateTetrahedrons.call(data, 'result')).toBe('result'); + expect(observed).toEqual([[0, 0, 0], [0, 0, 0]]); + expect(original).toHaveBeenCalledTimes(1); + }); + + it('installs only once and tolerates missing probe data', () => { + const original = jest.fn(); + const engine = createEngine(original); + + expect(installLightProbeNormalReset(engine)).toBe(true); + const patched = engine.internal.LightProbesData.prototype.updateTetrahedrons; + expect(installLightProbeNormalReset(engine)).toBe(false); + expect(engine.internal.LightProbesData.prototype.updateTetrahedrons).toBe(patched); + expect(() => patched.call({})).not.toThrow(); + expect(original).toHaveBeenCalledTimes(1); + expect(installLightProbeNormalReset({})).toBe(false); + }); +}); From 4e5a79dfbc233c74478997c3570cbf2c9b49ac4a Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 12:34:41 +0800 Subject: [PATCH 40/64] =?UTF-8?q?fix(lightmap):=20=E6=B8=85=E7=A9=BA?= =?UTF-8?q?=E5=90=8E=E7=B2=BE=E7=A1=AE=E5=88=A0=E9=99=A4=E7=83=98=E7=84=99?= =?UTF-8?q?=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/scene/lightfx-bake-schema.ts | 8 +- src/api/scene/lightfx-bake.ts | 7 +- src/core/scene/common/lightfx-bake.ts | 13 +++- src/core/scene/common/lightfx-host.ts | 14 +++- .../scene/main-process/lightfx-bake-host.ts | 53 +++++++++++-- .../service/baking/lightfx/baker.ts | 6 +- .../service/baking/lightfx/host.ts | 3 +- .../scene-process/service/lightmap-bake.ts | 44 +++++++++-- .../scene/test/lightfx-asset-versions.test.ts | 2 +- src/core/scene/test/lightfx-bake-host.test.ts | 77 ++++++++++++++++--- .../test/lightfx-scene-entrances.test.ts | 6 ++ .../test/lightmap-result-recording.test.ts | 41 +++++++++- 12 files changed, 234 insertions(+), 40 deletions(-) diff --git a/src/api/scene/lightfx-bake-schema.ts b/src/api/scene/lightfx-bake-schema.ts index 7bac75455..7d7ea5c9d 100644 --- a/src/api/scene/lightfx-bake-schema.ts +++ b/src/api/scene/lightfx-bake-schema.ts @@ -70,7 +70,13 @@ export const SchemaLightFXCancelResult = z.object({ export const SchemaLightProbeClearOptions = z.object({ saveScene: z.boolean().optional() }); export const SchemaLightmapClearOptions = z.object({ saveScene: z.boolean().optional(), deleteAssets: z.boolean().optional() }); -export const SchemaClearCountResult = z.object({ probeCount: z.number().int().nonnegative().optional(), clearedCount: z.number().int().nonnegative().optional() }); +export const SchemaClearCountResult = z.object({ + probeCount: z.number().int().nonnegative().optional(), + clearedCount: z.number().int().nonnegative().optional(), + deletedAssetCount: z.number().int().nonnegative().optional(), + retainedAssetCount: z.number().int().nonnegative().optional(), + failedAssetCount: z.number().int().nonnegative().optional(), +}); export type TLightProbeBakeOptions = z.infer; export type TLightProbeBakeResult = z.infer; diff --git a/src/api/scene/lightfx-bake.ts b/src/api/scene/lightfx-bake.ts index 7a20209f6..c51ce9be8 100644 --- a/src/api/scene/lightfx-bake.ts +++ b/src/api/scene/lightfx-bake.ts @@ -50,7 +50,12 @@ export class LightFXBakeApi { @title('Clear baked lightmap') @description('Unbind baked lightmaps from the current scene and optionally delete generated assets.') @result(SchemaClearCountResult) - clearLightmap(@param(SchemaLightmapClearOptions) options: { saveScene?: boolean; deleteAssets?: boolean }): Promise> { + clearLightmap(@param(SchemaLightmapClearOptions) options: { saveScene?: boolean; deleteAssets?: boolean }): Promise> { return execute(() => Scene.LightmapBake.clearBake(options)); } diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts index 6e14d486a..f7db81739 100644 --- a/src/core/scene/common/lightfx-bake.ts +++ b/src/core/scene/common/lightfx-bake.ts @@ -62,7 +62,7 @@ export interface ILightmapBakeOptions { timeoutMs?: number; } -/** Implementation support, not native executable readiness, task recovery or safe asset deletion. */ +/** Implementation support, not native executable readiness or task recovery. */ export interface ILightmapBakeCapabilities { /** The actual host accepts a selected assets output directory. */ outputDirectory?: true; @@ -73,6 +73,8 @@ export interface ILightmapBakeCapabilities { sceneTransactionVersion: 1; /** The actual host preserves previous textures in immutable per-operation directories. */ assetVersion: 1; + /** Clear saves first, then deletes exact unreferenced immutable LightFX texture assets. */ + assetCleanupVersion?: 1; /** Same-Scene cancellation requires the actual host ownership protocol. */ cancelVersion?: 1; /** Advisory: this Scene has obtained a native Lightmap operation ID. */ @@ -100,6 +102,13 @@ export interface ILightmapBakeInfo { missingTextureUuids: string[]; } +export interface ILightmapClearResult { + clearedCount: number; + deletedAssetCount: number; + retainedAssetCount: number; + failedAssetCount: number; +} + export interface ILightFXCancelResult { cancelled: boolean; target: 'light-probe' | 'lightmap' | null; @@ -124,7 +133,7 @@ export interface ILightmapBakeService extends IServiceEvents { queryCapabilities(): Promise; bake(options: ILightmapBakeOptions): Promise; queryBakeInfo(): Promise; - clearBake(options?: { saveScene?: boolean; deleteAssets?: boolean }): Promise<{ clearedCount: number }>; + clearBake(options?: { saveScene?: boolean; deleteAssets?: boolean }): Promise; /** Cancels only this Scene's lightmap bake after native ownership is acquired; otherwise a no-op. */ cancel(): Promise; } diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts index e7e058c22..db9e175c2 100644 --- a/src/core/scene/common/lightfx-host.ts +++ b/src/core/scene/common/lightfx-host.ts @@ -18,6 +18,8 @@ export interface ILightFXHostCapabilities { lightmapAssetVersion?: 1; /** Accepts an existing assets directory as the Lightmap output parent. */ lightmapOutputDirectory?: true; + /** Deletes only unreferenced immutable LightFX textures selected by exact UUID. */ + lightmapAssetCleanupVersion?: 1; /** Version 1 requires the exact native operation, target and scene reservation to cancel. */ cancelOwnershipVersion?: 1; diagnosticsVersion?: 1; @@ -113,7 +115,15 @@ export interface ICancelLightFXOperationOptions extends ILightFXOperationOptions export interface IRemoveLightmapAssetsOptions { transactionId?: string; - sceneName: string; + /** Saved scene whose dependency index may still report the bindings just cleared. */ + sceneUuid: string; + textureUuids: string[]; +} + +export interface IRemoveLightmapAssetsResult { + deletedTextureUuids: string[]; + retainedTextureUuids: string[]; + failures: Array<{ uuid: string; reason: string }>; } export interface IQueryLightmapTextureInfoOptions { @@ -152,6 +162,6 @@ export interface ILightFXBakeHostService { commit(options: ILightFXOperationOptions): Promise; rollback(options: ILightFXOperationOptions): Promise; cancel(options?: ICancelLightFXOperationOptions): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }>; - removeLightmapAssets(options: IRemoveLightmapAssetsOptions): Promise; + removeLightmapAssets(options: IRemoveLightmapAssetsOptions): Promise; queryLightmapTextureInfo(options: IQueryLightmapTextureInfoOptions): Promise; } diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index 4d02a4f39..3e7911652 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -24,6 +24,7 @@ import type { IQueryLightmapTextureInfoOptions, IQueryLightmapTextureInfoResult, IRemoveLightmapAssetsOptions, + IRemoveLightmapAssetsResult, IResolvedLightFXTextureSource, IResolveLightFXTextureSourceOptions, IRunLightFXBakeOptions, @@ -94,7 +95,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { private readonly releasedSceneOperations = new Set(); public async queryCapabilities(): Promise { - return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; + return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; } public async queryDiagnostics(options: ICancelLightFXOperationOptions): Promise { @@ -427,11 +428,15 @@ export class LightFXBakeHost implements ILightFXBakeHostService { return { cancelled: true, target: operation.target }; } - public async removeLightmapAssets(options: IRemoveLightmapAssetsOptions): Promise { + public async removeLightmapAssets(options: IRemoveLightmapAssetsOptions): Promise { if (this.operation) { throw new Error(`A ${this.operation.target} LightFX bake is already in progress.`); } - this.validateSceneName(options.sceneName); + if (!options || typeof options.sceneUuid !== 'string' || !Array.isArray(options.textureUuids) || options.textureUuids.length > MAX_TEXTURE_SOURCES) { + throw new Error('Invalid Lightmap texture UUID list.'); + } + const sceneUuid = Utils.UUID.decompressUUID(options.sceneUuid).split('@', 1)[0]; + if (!Utils.UUID.isUUID(sceneUuid)) throw new Error('Invalid Lightmap scene UUID.'); this.validateSceneOperation(options.transactionId, 'lightmap', 'clear'); const legacy = options.transactionId === undefined; const token = legacy ? await this.reserveSceneOperation({ target: 'lightmap', action: 'clear' }) : { transactionId: options.transactionId! }; @@ -439,9 +444,45 @@ export class LightFXBakeHost implements ILightFXBakeHostService { if (owner.removingAssets) throw new Error('Lightmap assets are already being removed.'); owner.removingAssets = true; try { - const targetDir = join(this.queryAssetRoot(), options.sceneName, 'lightmap'); - await remove(targetDir); - await assetManager.refreshAsset(`db://assets/${options.sceneName}`); + const uuids = [...new Set(options.textureUuids.map(value => { + if (typeof value !== 'string') throw new Error('Invalid Lightmap texture UUID.'); + const uuid = Utils.UUID.decompressUUID(value).split('@', 1)[0]; + if (!Utils.UUID.isUUID(uuid)) throw new Error('Invalid Lightmap texture UUID.'); + return uuid; + }))]; + const result: IRemoveLightmapAssetsResult = { deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }; + for (const uuid of uuids) { + const info = assetManager.queryAssetInfo(uuid); + const parts = info?.url?.startsWith('db://assets/') ? info.url.slice('db://assets/'.length).split('/') : []; + const filename = parts.at(-1) ?? ''; + const version = parts.at(-2) ?? ''; + if (!info?.url || !/^LFX_(?:Mesh|Terrain)_\d{4,}\.png$/.test(filename) + || !version.startsWith('bake-') || !Utils.UUID.isUUID(version.slice('bake-'.length))) { + result.failures.push({ uuid, reason: 'Asset is not an immutable LightFX texture.' }); + continue; + } + try { + const users = await assetManager.queryAssetUsers(uuid); + const hasOtherUser = users.some((user) => { + try { + const userUuid = Utils.UUID.decompressUUID(user).split('@', 1)[0]; + return userUuid !== sceneUuid && userUuid !== uuid; + } catch { + // An unknown dependency identifier is retained conservatively. + return true; + } + }); + if (hasOtherUser) { + result.retainedTextureUuids.push(uuid); + continue; + } + await assetManager.removeAsset(uuid); + result.deletedTextureUuids.push(uuid); + } catch (error) { + result.failures.push({ uuid, reason: error instanceof Error ? error.message : String(error) }); + } + } + return result; } finally { owner.removingAssets = false; if (legacy) await this.releaseSceneOperation(token); diff --git a/src/core/scene/scene-process/service/baking/lightfx/baker.ts b/src/core/scene/scene-process/service/baking/lightfx/baker.ts index 59dd6229a..d17beb41a 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/baker.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/baker.ts @@ -5,7 +5,7 @@ import { LightFXExporter, LightFXExport } from './exporter'; import { lightFXBakeHost } from './host'; import { lightFXSceneOperation } from './scene-operation'; import { LightFXBakeTarget, LightFXResult, LightFXSettings } from './types'; -import type { ICancelLightFXOperationOptions, ILightFXDiagnostics } from '../../../../common/lightfx-host'; +import type { ICancelLightFXOperationOptions, ILightFXDiagnostics, IRemoveLightmapAssetsResult } from '../../../../common/lightfx-host'; const INPUT_CHUNK_SIZE = 512 * 1024; @@ -89,8 +89,8 @@ export class LightFXCoordinator { } } - removeLightmapAssets(sceneName: string): Promise { - return lightFXBakeHost.removeLightmapAssets({ sceneName, transactionId: lightFXSceneOperation.hostTransactionId }); + removeLightmapAssets(sceneUuid: string, textureUuids: string[]): Promise { + return lightFXBakeHost.removeLightmapAssets({ sceneUuid, textureUuids, transactionId: lightFXSceneOperation.hostTransactionId }); } async cancel(target: LightFXBakeTarget): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }> { diff --git a/src/core/scene/scene-process/service/baking/lightfx/host.ts b/src/core/scene/scene-process/service/baking/lightfx/host.ts index c9ef9330d..a87e48c18 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/host.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/host.ts @@ -7,6 +7,7 @@ import type { IQueryLightmapTextureInfoOptions, IQueryLightmapTextureInfoResult, IRemoveLightmapAssetsOptions, + IRemoveLightmapAssetsResult, IResolveLightFXTextureSourceOptions, IResolvedLightFXTextureSource, IRunLightFXBakeOptions, @@ -28,6 +29,6 @@ export const lightFXBakeHost: ILightFXBakeHostService = { commit: (options: ILightFXOperationOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'commit', [options]), rollback: (options: ILightFXOperationOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'rollback', [options]), cancel: (options?: ICancelLightFXOperationOptions): Promise<{ cancelled: boolean; target: 'light-probe' | 'lightmap' | null }> => Rpc.getInstance().request('lightFXBakeHost', 'cancel', [options]), - removeLightmapAssets: (options: IRemoveLightmapAssetsOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'removeLightmapAssets', [options]), + removeLightmapAssets: (options: IRemoveLightmapAssetsOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'removeLightmapAssets', [options]), queryLightmapTextureInfo: (options: IQueryLightmapTextureInfoOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'queryLightmapTextureInfo', [options]), }; diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index 4b9437f59..9c58c92d2 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -1,7 +1,7 @@ import { director, MeshRenderer, Scene, Terrain, Texture2D } from 'cc'; import type { ILightFXBakeEvents, ILightFXCancelResult, ILightmapBakeOptions, - ILightmapBakeInfo, ILightmapBakeResult, ILightmapBakeService, ILightmapBakeCapabilities, + ILightmapBakeInfo, ILightmapBakeResult, ILightmapBakeService, ILightmapBakeCapabilities, ILightmapClearResult, } from '../../common'; import { Rpc } from '../rpc'; import { lightFXCoordinator } from './baking/lightfx/baker'; @@ -29,6 +29,7 @@ export class LightmapBakeService extends BaseService impleme } return { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, ...(host.lightmapOutputDirectory === true ? { outputDirectory: true as const } : {}), + ...(host.lightmapAssetCleanupVersion === 1 ? { assetCleanupVersion: 1 as const } : {}), ...(host.diagnosticsVersion === 1 ? { diagnostics: await lightFXCoordinator.queryDiagnostics('lightmap') } : {}), ...(host.cancelOwnershipVersion === 1 ? { cancelVersion: 1 as const, cancellable: lightFXCoordinator.canCancel('lightmap') } : {}), busy: host.busy }; } @@ -158,15 +159,20 @@ export class LightmapBakeService extends BaseService impleme }; } - async clearBake(options: { saveScene?: boolean; deleteAssets?: boolean } = {}): Promise<{ clearedCount: number }> { + async clearBake(options: { saveScene?: boolean; deleteAssets?: boolean } = {}): Promise { return lightFXSceneOperation.run('lightmap', 'clear', () => this.clearBakeExclusive(options)); } - private async clearBakeExclusive(options: { saveScene?: boolean; deleteAssets?: boolean }): Promise<{ clearedCount: number }> { + private async clearBakeExclusive(options: { saveScene?: boolean; deleteAssets?: boolean }): Promise { const scene = director.getScene() as Scene | null; if (!scene) throw new Error('No scene is currently open.'); + if (options.deleteAssets === true && options.saveScene === false) { + throw new Error('deleteAssets requires saveScene so the saved scene cannot retain deleted lightmap references.'); + } const bindings = this.snapshotSceneBindings(scene); + const textureUuids = [...new Set(bindings.map(binding => binding.texture?.uuid ?? (binding.texture as any)?._uuid) + .filter((uuid): uuid is string => typeof uuid === 'string' && uuid.length > 0))]; const previousHighp = (scene.globals as any).bakedWithHighpLightmap; const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; const targets = [...new Set(bindings.map(binding => binding.target.uuid as string)), scene.uuid]; @@ -176,8 +182,24 @@ export class LightmapBakeService extends BaseService impleme (scene.globals as any).bakedWithHighpLightmap = false; (scene.globals as any).bakedWithStationaryMainLight = false; await Service.Engine.repaintInEditMode(); - await finishSavedLightFXRecording(Service.Undo, undo, - options.saveScene !== false ? () => Service.Editor.save({}) : undefined); + if (options.deleteAssets === true) { + try { + await Service.Editor.save({}); + } catch (error) { + try { + await Service.Undo.endRecording(undo); + } catch (recordingError) { + throw new LightFXResultRetainedError('recording', recordingError); + } + throw new LightFXResultRetainedError('save', error); + } + // The saved scene is the new baseline. Discard only this still-active recording so + // Undo cannot restore references to texture assets that are about to be deleted. + Service.Undo.cancelRecording(undo); + } else { + await finishSavedLightFXRecording(Service.Undo, undo, + options.saveScene !== false ? () => Service.Editor.save({}) : undefined); + } } catch (error) { if (error instanceof LightFXResultRetainedError) throw error; Service.Undo.cancelRecording(undo); @@ -188,10 +210,16 @@ export class LightmapBakeService extends BaseService impleme throw error; } - if (options.deleteAssets) { - await lightFXCoordinator.removeLightmapAssets(scene.name); + if (options.deleteAssets === true) { + const result = await lightFXCoordinator.removeLightmapAssets(scene.uuid, textureUuids); + return { + clearedCount: bindings.length, + deletedAssetCount: result.deletedTextureUuids.length, + retainedAssetCount: result.retainedTextureUuids.length, + failedAssetCount: result.failures.length, + }; } - return { clearedCount: bindings.length }; + return { clearedCount: bindings.length, deletedAssetCount: 0, retainedAssetCount: 0, failedAssetCount: 0 }; } cancel(): Promise { diff --git a/src/core/scene/test/lightfx-asset-versions.test.ts b/src/core/scene/test/lightfx-asset-versions.test.ts index f989e4d24..f32535e92 100644 --- a/src/core/scene/test/lightfx-asset-versions.test.ts +++ b/src/core/scene/test/lightfx-asset-versions.test.ts @@ -104,6 +104,6 @@ describe('Immutable Lightmap asset versions', () => { mockAssets.refreshAsset.mockRejectedValueOnce(new Error('import unavailable')); await expect(bake('pixels B')).rejects.toThrow('import unavailable'); expect(await readFile(a.path, 'utf8')).toBe('pixels A'); - await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }); + await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }); }); }); diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts index 76de38707..be793403b 100644 --- a/src/core/scene/test/lightfx-bake-host.test.ts +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -7,6 +7,8 @@ const mockAssetManager = { refreshAsset: jest.fn(), queryUUID: jest.fn(), queryAssetInfo: jest.fn(), + queryAssetUsers: jest.fn(), + removeAsset: jest.fn(), queryAssetMeta: jest.fn(), saveAssetMeta: jest.fn(), }; @@ -31,6 +33,7 @@ jest.mock('../main-process/lightfx/output', () => ({ import { LightFXBakeHost, parseLightFXProgressRate } from '../main-process/lightfx-bake-host'; describe('LightFXBakeHost', () => { + const sceneUuid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; let root: string; let assetRoot: string; let host: LightFXBakeHost; @@ -44,6 +47,8 @@ describe('LightFXBakeHost', () => { mockAssetManager.refreshAsset.mockReset().mockResolvedValue(undefined); mockAssetManager.queryUUID.mockReset(); mockAssetManager.queryAssetInfo.mockReset(); + mockAssetManager.queryAssetUsers.mockReset().mockResolvedValue([]); + mockAssetManager.removeAsset.mockReset().mockResolvedValue({}); mockAssetManager.queryAssetMeta.mockReset(); mockAssetManager.saveAssetMeta.mockReset(); mockRunnerRun.mockReset(); @@ -85,7 +90,7 @@ describe('LightFXBakeHost', () => { }); it('queries protocol and occupancy without reserving, releasing or exposing ownership', async () => { - const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }; + const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }; const busy = { ...idle, busy: true }; await expect(host.queryCapabilities()).resolves.toEqual(idle); const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); @@ -166,33 +171,81 @@ describe('LightFXBakeHost', () => { it('rejects invalid reservation and clear credentials without deleting assets', async () => { await expect(host.reserveSceneOperation({ target: 'invalid' as any, action: 'clear' })).rejects.toThrow('Invalid'); await expect(host.releaseSceneOperation({ transactionId: '' })).rejects.toThrow('Invalid'); - const file = join(assetRoot, 'Fixture', 'lightmap', 'owned.png'); - await outputFile(file, 'preserve'); + const uuid = '11111111-1111-4111-8111-111111111111'; const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'clear' }); - await expect(host.removeLightmapAssets({ sceneName: 'Fixture', ...token })).rejects.toThrow('ownership'); - await expect(host.removeLightmapAssets({ sceneName: 'Fixture' })).rejects.toThrow('ownership'); - await expect(readFile(file, 'utf8')).resolves.toBe('preserve'); + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid], ...token })).rejects.toThrow('ownership'); + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid] })).rejects.toThrow('ownership'); + expect(mockAssetManager.removeAsset).not.toHaveBeenCalled(); await host.releaseSceneOperation(token); }); - it.each([false, true])('keeps deletion and asset refresh locked (legacy=%s)', async (legacy) => { + it.each([false, true])('keeps exact asset deletion locked (legacy=%s)', async (legacy) => { let finish!: () => void; let entered!: () => void; - const enteredRefresh = new Promise(resolve => { entered = resolve; }); - mockAssetManager.refreshAsset.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; entered(); })); + const enteredRemoval = new Promise(resolve => { entered = resolve; }); + const uuid = '11111111-1111-4111-8111-111111111111'; + mockAssetManager.queryAssetInfo.mockReturnValue({ uuid, url: `db://assets/Maps/bake-22222222-2222-4222-8222-222222222222/LFX_Mesh_0000.png` }); + mockAssetManager.removeAsset.mockImplementationOnce(() => new Promise(resolve => { finish = () => resolve({}); entered(); })); const token = legacy ? undefined : await host.reserveSceneOperation({ target: 'lightmap', action: 'clear' }); - const removing = host.removeLightmapAssets({ sceneName: 'Fixture', ...token }); - await enteredRefresh; + const removing = host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid], ...token }); + await enteredRemoval; await expect(host.reserveSceneOperation({ target: 'light-probe', action: 'bake' })).rejects.toThrow('already in progress'); if (token) { await expect(host.releaseSceneOperation(token)).rejects.toThrow('cleanup has not finished'); - await expect(host.removeLightmapAssets({ sceneName: 'Fixture', ...token })).rejects.toThrow('already being removed'); + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid], ...token })).rejects.toThrow('already being removed'); } finish(); await removing; if (token) await host.releaseSceneOperation(token); await expect(host.reserveSceneOperation({ target: 'light-probe', action: 'bake' })).resolves.toHaveProperty('transactionId'); }); + it('deletes only exact unreferenced immutable LightFX textures', async () => { + const deleted = '11111111-1111-4111-8111-111111111111'; + const retained = '22222222-2222-4222-8222-222222222222'; + const invalid = '33333333-3333-4333-8333-333333333333'; + const failed = '44444444-4444-4444-8444-444444444444'; + mockAssetManager.queryAssetInfo.mockImplementation((uuid: string) => ({ + uuid, + url: uuid === invalid + ? 'db://assets/User/texture.png' + : `db://assets/Maps/bake-55555555-5555-4555-8555-555555555555/LFX_Terrain_0000.png`, + })); + mockAssetManager.queryAssetUsers.mockImplementation(async (uuid: string) => uuid === retained + ? ['66666666-6666-4666-8666-666666666666'] + : uuid === deleted ? [`${deleted}@6c48a`, sceneUuid] : []); + mockAssetManager.removeAsset.mockImplementation(async (uuid: string) => { + if (uuid === failed) throw new Error('trash unavailable'); + return {}; + }); + + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [`${deleted}@6c48a`, deleted, retained, invalid, failed] })).resolves.toEqual({ + deletedTextureUuids: [deleted], + retainedTextureUuids: [retained], + failures: [ + { uuid: invalid, reason: 'Asset is not an immutable LightFX texture.' }, + { uuid: failed, reason: 'trash unavailable' }, + ], + }); + expect(mockAssetManager.queryAssetUsers).toHaveBeenCalledTimes(3); + expect(mockAssetManager.removeAsset).toHaveBeenCalledTimes(2); + }); + + it('reports dependency query failures without attempting that deletion', async () => { + const uuid = '11111111-1111-4111-8111-111111111111'; + mockAssetManager.queryAssetInfo.mockReturnValue({ + uuid, + url: 'db://assets/Maps/bake-22222222-2222-4222-8222-222222222222/LFX_Mesh_0000.png', + }); + mockAssetManager.queryAssetUsers.mockRejectedValueOnce(new Error('dependency index unavailable')); + + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid] })).resolves.toEqual({ + deletedTextureUuids: [], + retainedTextureUuids: [], + failures: [{ uuid, reason: 'dependency index unavailable' }], + }); + expect(mockAssetManager.removeAsset).not.toHaveBeenCalled(); + }); + it('reserves against legacy native operations and keeps ownership after begin validation failure', async () => { const operationId = await finishLightProbe(); await expect(host.reserveSceneOperation({ target: 'lightmap', action: 'clear' })).rejects.toThrow('already in progress'); diff --git a/src/core/scene/test/lightfx-scene-entrances.test.ts b/src/core/scene/test/lightfx-scene-entrances.test.ts index 902e8489f..3ec2aa74b 100644 --- a/src/core/scene/test/lightfx-scene-entrances.test.ts +++ b/src/core/scene/test/lightfx-scene-entrances.test.ts @@ -23,6 +23,12 @@ describe('LightFX service entrance ownership', () => { expect((await new LightmapBakeService().queryCapabilities()).outputDirectory).toBe(supported ? true : undefined); } }); + it('advertises exact asset cleanup only when the actual host supports it', async () => { + for (const supported of [false, true]) { + jest.mocked(lightFXBakeHost.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, busy: false, ...(supported ? { lightmapAssetCleanupVersion: 1 as const } : {}) }); + expect((await new LightmapBakeService().queryCapabilities()).assetCleanupVersion).toBe(supported ? 1 : undefined); + } + }); it.each([false, true])('advertises actual Lightmap cancellation readiness (%s)', async cancellable => { jest.mocked(lightFXCoordinator.canCancel).mockReturnValueOnce(cancellable); jest.mocked(lightFXBakeHost.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, busy: true }); diff --git a/src/core/scene/test/lightmap-result-recording.test.ts b/src/core/scene/test/lightmap-result-recording.test.ts index bd295ce94..5792ba2be 100644 --- a/src/core/scene/test/lightmap-result-recording.test.ts +++ b/src/core/scene/test/lightmap-result-recording.test.ts @@ -4,6 +4,7 @@ const mockTerrain = class Terrain {}; const mockBake = jest.fn(); const mockCommit = jest.fn(); const mockRollback = jest.fn(); +const mockRemoveLightmapAssets = jest.fn(); const mockUndo = { beginRecording: jest.fn(() => 'recording'), endRecording: jest.fn(async () => undefined), @@ -17,7 +18,7 @@ jest.mock('../scene-process/service/core', () => ({ BaseService: class { broadcast() {} }, register: () => () => undefined, Service: { Undo: mockUndo, Editor: { save: mockSave }, Engine: { repaintInEditMode: async () => undefined } }, })); -jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { bake: mockBake, commit: mockCommit, rollback: mockRollback } })); +jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { bake: mockBake, commit: mockCommit, rollback: mockRollback, removeLightmapAssets: mockRemoveLightmapAssets } })); jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { reserveSceneOperation: async () => ({ transactionId: 'owner' }), releaseSceneOperation: async () => undefined, } })); @@ -49,7 +50,10 @@ function fixture() { } describe('Lightmap result recording targets', () => { - beforeEach(() => jest.clearAllMocks()); + beforeEach(() => { + jest.clearAllMocks(); + mockRemoveLightmapAssets.mockResolvedValue({ deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }); + }); it.each([false, true])('records Mesh and Terrain components before scene flags for Bake (save=%s)', async saveScene => { const f = fixture(); await f.service.bake({ saveScene }); @@ -63,7 +67,9 @@ describe('Lightmap result recording targets', () => { }); it.each([false, true])('deduplicates multiple Terrain blocks and records all cleared bindings (save=%s)', async saveScene => { const f = fixture(); - await expect(f.service.clearBake({ saveScene })).resolves.toEqual({ clearedCount: 3 }); + await expect(f.service.clearBake({ saveScene })).resolves.toEqual({ + clearedCount: 3, deletedAssetCount: 0, retainedAssetCount: 0, failedAssetCount: 0, + }); expect(mockUndo.beginRecording).toHaveBeenCalledWith(['mesh', 'terrain', 'scene'], { label: 'Clear lightmap' }); expect(f.model._updateLightmap).toHaveBeenCalledWith(null, 0, 0, 0, 0); expect(f.terrain._updateLightmap).toHaveBeenCalledWith(0, null, 0, 0, 0, 0); @@ -72,6 +78,35 @@ describe('Lightmap result recording targets', () => { expect(mockSave).toHaveBeenCalledTimes(saveScene ? 1 : 0); expect(mockUndo.markSaved).not.toHaveBeenCalled(); }); + it('saves before exact deletion and discards only the Clear recording', async () => { + const f = fixture(); + mockRemoveLightmapAssets.mockResolvedValueOnce({ + deletedTextureUuids: ['old-texture'], retainedTextureUuids: ['shared'], failures: [{ uuid: 'failed', reason: 'busy' }], + }); + await expect(f.service.clearBake({ deleteAssets: true })).resolves.toEqual({ + clearedCount: 3, deletedAssetCount: 1, retainedAssetCount: 1, failedAssetCount: 1, + }); + expect(mockSave).toHaveBeenCalledTimes(1); + expect(mockUndo.cancelRecording).toHaveBeenCalledWith('recording'); + expect(mockUndo.endRecording).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).toHaveBeenCalledWith('scene', ['old-texture']); + expect(mockSave.mock.invocationCallOrder[0]).toBeLessThan(mockRemoveLightmapAssets.mock.invocationCallOrder[0]); + }); + it('rejects deletion without saving before changing the scene', async () => { + const f = fixture(); + await expect(f.service.clearBake({ saveScene: false, deleteAssets: true })).rejects.toThrow('deleteAssets requires saveScene'); + expect(f.model._updateLightmap).not.toHaveBeenCalled(); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + }); + it('does not delete assets when the required save is unconfirmed', async () => { + const f = fixture(); + mockSave.mockRejectedValueOnce(new Error('save response lost')); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('result retained'); + expect(mockUndo.endRecording).toHaveBeenCalledWith('recording'); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + }); it('retains cleared bindings and history if saving fails', async () => { const f = fixture(); mockSave.mockRejectedValueOnce(new Error('disk unavailable')); From 6dd8072af0980ff5eee041b30347cb1608e8e26f Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 15:32:03 +0800 Subject: [PATCH 41/64] test(types): update DTS snapshot for LightFX APIs --- .../__snapshots__/dts-snapshot.test.ts.snap | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) 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 d2a304a66..020488130 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 @@ -6725,19 +6725,21 @@ export declare interface ILightFXDiagnostics { stage: string; logs: string[]; progress?: string; + rate?: number; } export declare interface ILightmapBakeCapabilities { + outputDirectory?: true; diagnostics?: ILightFXDiagnostics; version: 1; resultLifecycleVersion: 1; sceneTransactionVersion: 1; assetVersion: 1; + assetCleanupVersion?: 1; cancelVersion?: 1; cancellable?: boolean; busy: boolean; } export declare interface ILightmapBakeInfo { - readiness?: ILightmapReadiness; sceneUrl: string; baked: boolean; meshCount: number; @@ -6748,6 +6750,7 @@ export declare interface ILightmapBakeInfo { missingTextureUuids: string[]; } export declare interface ILightmapBakeOptions { + outputUrl?: string; msaa?: 1 | 2 | 4 | 8; resolution?: 128 | 256 | 512 | 1024 | 2048; filter?: boolean; @@ -6778,22 +6781,14 @@ export declare interface ILightmapBakeService extends IServiceEvents { clearBake(options?: { saveScene?: boolean; deleteAssets?: boolean; - }): Promise<{ - clearedCount: number; - }>; + }): Promise; cancel(): Promise; } -export declare interface ILightmapReadiness { - version: 1; - objects: { - componentUuid: string; - nodeName: string; - kind: 'mesh' | 'terrain'; - receivesLightmap: boolean; - castsShadow: boolean; - lightmapSize: number; - issues: LightmapObjectIssue[]; - }[]; +export declare interface ILightmapClearResult { + clearedCount: number; + deletedAssetCount: number; + retainedAssetCount: number; + failedAssetCount: number; } export declare interface ILightmapTextureInfo { uuid: string; @@ -7733,7 +7728,6 @@ export declare interface LabelAtlasAssetUserData { spriteFrameUuid: string; _fntConfig: FntData; } -export declare type LightmapObjectIssue = 'inactive' | 'movable' | 'editor-only' | 'disabled' | 'not-participating' | 'missing-mesh' | 'invalid-uv1' | 'skinned-static-pose' | 'material-approximation' | 'terrain-translation-only'; export declare interface LODsOption { screenRatio: number; faceCount: number; From 0b3fd8eda971334f0bb865210e78a3b508c1ab27 Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 16:28:44 +0800 Subject: [PATCH 42/64] fix(lightmap): harden destructive clear --- docs/dev/scene/lightfx-bake.md | 15 +++-- src/api/scene/lightfx-bake.ts | 2 +- src/core/scene/common/lightfx-bake.ts | 1 + src/core/scene/common/lightfx-host.ts | 2 +- .../scene-process/service/lightmap-bake.ts | 53 ++++++++++++++++-- .../test/lightmap-result-recording.test.ts | 56 ++++++++++++++++++- 6 files changed, 112 insertions(+), 17 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 1b1f354f7..40a32968d 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -36,10 +36,11 @@ Lightmap 使用独立能力查询,不能复用 Probe 的生命周期判断: ```ts const capabilities = await cli.Scene.LightmapBake.queryCapabilities(); -// { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, busy: false } +// { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, +// outputDirectory: true, assetCleanupVersion: 1, busy: false } ``` -这里的 `resultLifecycleVersion: 1` 包含 Mesh/Terrain 的结果录制目标、空纹理引用、TerrainBlock 恢复刷新及保存基线;`assetVersion: 1` 必须由实际 Node host 的 `lightmapAssetVersion: 1` 确认,保证新 Bake 不覆盖旧纹理版本。旧 host 即使支持 Probe 事务,也可能缺少资产版本保护,此时 Lightmap 查询拒绝返回支持。该能力只覆盖保留资产的 Clear,不承诺 deleteAssets 删除归属或资产 GC;有归属取消另通过 `cancelVersion`/`cancellable` 声明,见下文。 +这里的 `resultLifecycleVersion: 1` 包含 Mesh/Terrain 的结果录制目标、空纹理引用、TerrainBlock 恢复刷新及保存基线;`assetVersion: 1` 必须由实际 Node host 的 `lightmapAssetVersion: 1` 确认,保证新 Bake 不覆盖旧纹理版本。旧 host 即使支持 Probe 事务,也可能缺少资产版本保护,此时 Lightmap 查询拒绝返回支持。`outputDirectory` 和 `assetCleanupVersion` 分别表示实际 Host 支持安全的自选输出目录和精确资产清理;字段缺失时不得调用对应能力。有归属取消另通过 `cancelVersion`/`cancellable` 声明,见下文。 `busy` 仅为共享宿主的瞬时占用提示,包含导出前预留、原生操作、提交后场景回写及失败恢复;查询不占锁、不释放锁、不返回内部凭据。即使 busy=false,执行入口仍需原子预留,调用方必须处理查询之后发生的并发拒绝。该接口不检查原生 LightFX 可执行文件、场景输入合法性或渲染质量,也不是持久任务/统一百分比协议。上方示例仅列基础字段;可选取消能力和原生诊断见下文。新旧 renderer 混用的限制仍见下文。 @@ -284,7 +285,7 @@ Pink 应在场景打开、烘焙完成和清理完成后调用该工具刷新面 } ``` -解除绑定并删除当前场景生成的 Lightmap 目录: +解除绑定并删除没有其他引用的不可变 LightFX Lightmap 贴图: ```json { @@ -295,7 +296,9 @@ Pink 应在场景打开、烘焙完成和清理完成后调用该工具刷新面 } ``` -`saveScene` 默认为 `true`,`deleteAssets` 默认为 `false`。成功结果中的 `clearedCount` 是解除绑定的 Mesh 和 Terrain block 总数。 +`saveScene` 默认为 `true`,`deleteAssets` 默认为 `false`。调用 `deleteAssets:true` 前必须确认 `queryCapabilities().assetCleanupVersion === 1`;服务也会在修改场景前再次校验实际 Host 能力。成功结果中的 `clearedCount` 是解除绑定的 Mesh 和 Terrain block 总数,`deletedAssetCount`、`retainedAssetCount` 和 `failedAssetCount` 分别表示删除、因引用保留和删除失败的贴图数量。 + +删除模式先清空绑定,再序列化实时场景检查候选贴图是否仍被其他字段引用;仍存在的根资源或子资源引用会保留。场景保存成功后清空整个 Scene Undo/Redo 历史,防止更早的 Bake 记录通过 Redo 恢复已经删除的 UUID。Host 仅逐项删除 Asset DB 可验证的不可变 LightFX 贴图,不删除父目录或同目录的其他文件;其他资产仍引用、依赖查询失败或删除失败时保留并报告。 ### 取消烘焙 @@ -387,9 +390,9 @@ LightFX 当前可能输出 Creator 历史协议版本。解析器只接受已知 - 已有另一个 LightFX 任务运行。 - 当前可见场景尚未加载完成,或同时存在多个可见的场景渲染器。 -Bake 和 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与结果修改的 MeshRenderer/Terrain 组件(同一 Terrain 多 block 去重)及 Scene 标记,不能只录制不递归的 Scene 根节点;旧引擎缺类型的空纹理引用也会保留在快照中。成功自动保存后以该记录作为保存点;saveScene:false 不隐式保存场景。失败必须区分原生提交前、结果应用中和结果录制后保存阶段,不能对所有错误统一恢复绑定或删除资产,详见“提交与保存失败”。 +Bake 和非删除模式 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与结果修改的 MeshRenderer/Terrain 组件(同一 Terrain 多 block 去重)及 Scene 标记,不能只录制不递归的 Scene 根节点;旧引擎缺类型的空纹理引用也会保留在快照中。成功自动保存后以该记录作为保存点;saveScene:false 不隐式保存场景。`deleteAssets:true` 是例外:保存成功后清空整个 Scene Undo/Redo 历史,避免当前或更早的 Bake 记录恢复已删除资源。失败必须区分原生提交前、结果应用中和结果录制后保存阶段,不能对所有错误统一恢复绑定或删除资产,详见“提交与保存失败”。 -绑定历史与资产版本分别保留:新版本输出不会覆盖旧 PNG,Undo 通过旧 UUID 恢复旧纹理/UV/场景标记。此前旧版本 CLI 已覆盖丢失的像素无法靠此修复找回。`deleteAssets:true` 仍会删除同 sceneName 的整个 lightmap 目录,包括所有版本;不属于可恢复绑定的保留资产验证范围,调用方不可据此假定删除可撤销或跨同名场景安全。 +绑定历史与资产版本分别保留:新版本输出不会覆盖旧 PNG,普通 Bake/Clear 的 Undo 通过旧 UUID 恢复旧纹理/UV/场景标记。此前旧版本 CLI 已覆盖丢失的像素无法靠此修复找回。`deleteAssets:true` 只处理 Clear 前实际绑定、且可验证为不可变 LightFX 版本产物的根贴图 UUID;不会删除整个目录。当前实时场景或其他磁盘资产仍引用的贴图会保留,删除操作不可撤销。 ## 验证范围 diff --git a/src/api/scene/lightfx-bake.ts b/src/api/scene/lightfx-bake.ts index c51ce9be8..240d8ece7 100644 --- a/src/api/scene/lightfx-bake.ts +++ b/src/api/scene/lightfx-bake.ts @@ -48,7 +48,7 @@ export class LightFXBakeApi { @tool('scene-clear-lightmap') @title('Clear baked lightmap') - @description('Unbind baked lightmaps from the current scene and optionally delete generated assets.') + @description('Unbind baked lightmaps and optionally delete unreferenced generated assets. Asset deletion saves the scene and clears its Undo/Redo history.') @result(SchemaClearCountResult) clearLightmap(@param(SchemaLightmapClearOptions) options: { saveScene?: boolean; deleteAssets?: boolean }): Promise; bake(options: ILightmapBakeOptions): Promise; queryBakeInfo(): Promise; + /** Asset deletion saves the scene and clears all Scene Undo/Redo history before removing textures. */ clearBake(options?: { saveScene?: boolean; deleteAssets?: boolean }): Promise; /** Cancels only this Scene's lightmap bake after native ownership is acquired; otherwise a no-op. */ cancel(): Promise; diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts index db9e175c2..4a6ca0858 100644 --- a/src/core/scene/common/lightfx-host.ts +++ b/src/core/scene/common/lightfx-host.ts @@ -115,7 +115,7 @@ export interface ICancelLightFXOperationOptions extends ILightFXOperationOptions export interface IRemoveLightmapAssetsOptions { transactionId?: string; - /** Saved scene whose dependency index may still report the bindings just cleared. */ + /** Saved scene whose stale dependency entry may be ignored after Scene verified no live reference remains. */ sceneUuid: string; textureUuids: string[]; } diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index 9c58c92d2..90db61801 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -169,20 +169,27 @@ export class LightmapBakeService extends BaseService impleme if (options.deleteAssets === true && options.saveScene === false) { throw new Error('deleteAssets requires saveScene so the saved scene cannot retain deleted lightmap references.'); } + if (options.deleteAssets === true + && (await lightFXBakeHost.queryCapabilities())?.lightmapAssetCleanupVersion !== 1) { + throw new Error('The LightFX host does not support exact Lightmap asset cleanup.'); + } const bindings = this.snapshotSceneBindings(scene); const textureUuids = [...new Set(bindings.map(binding => binding.texture?.uuid ?? (binding.texture as any)?._uuid) - .filter((uuid): uuid is string => typeof uuid === 'string' && uuid.length > 0))]; + .filter((uuid): uuid is string => typeof uuid === 'string' && uuid.length > 0) + .map(uuid => this.rootAssetUuid(uuid)))]; const previousHighp = (scene.globals as any).bakedWithHighpLightmap; const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; const targets = [...new Set(bindings.map(binding => binding.target.uuid as string)), scene.uuid]; const undo = Service.Undo.beginRecording(targets, { label: 'Clear lightmap' }); + let retainedSceneTextureUuids = new Set(); try { this.clearBindings(bindings); (scene.globals as any).bakedWithHighpLightmap = false; (scene.globals as any).bakedWithStationaryMainLight = false; await Service.Engine.repaintInEditMode(); if (options.deleteAssets === true) { + retainedSceneTextureUuids = await this.queryRemainingSceneTextureUuids(textureUuids); try { await Service.Editor.save({}); } catch (error) { @@ -193,9 +200,6 @@ export class LightmapBakeService extends BaseService impleme } throw new LightFXResultRetainedError('save', error); } - // The saved scene is the new baseline. Discard only this still-active recording so - // Undo cannot restore references to texture assets that are about to be deleted. - Service.Undo.cancelRecording(undo); } else { await finishSavedLightFXRecording(Service.Undo, undo, options.saveScene !== false ? () => Service.Editor.save({}) : undefined); @@ -211,17 +215,54 @@ export class LightmapBakeService extends BaseService impleme } if (options.deleteAssets === true) { - const result = await lightFXCoordinator.removeLightmapAssets(scene.uuid, textureUuids); + // This is intentionally outside the rollback block: the cleared scene is already saved, + // so a notification failure must not restore only the in-memory bindings. Asset deletion + // cannot participate in Scene Undo; reset the entire stack before removing any texture. + Service.Undo.clearHistory(); + const deletableTextureUuids = textureUuids.filter(uuid => !retainedSceneTextureUuids.has(uuid)); + const result = deletableTextureUuids.length > 0 + ? await lightFXCoordinator.removeLightmapAssets(scene.uuid, deletableTextureUuids) + : { deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }; return { clearedCount: bindings.length, deletedAssetCount: result.deletedTextureUuids.length, - retainedAssetCount: result.retainedTextureUuids.length, + retainedAssetCount: retainedSceneTextureUuids.size + result.retainedTextureUuids.length, failedAssetCount: result.failures.length, }; } return { clearedCount: bindings.length, deletedAssetCount: 0, retainedAssetCount: 0, failedAssetCount: 0 }; } + /** Returns candidates still referenced by the live scene after its Lightmap bindings are cleared. */ + private async queryRemainingSceneTextureUuids(textureUuids: readonly string[]): Promise> { + const candidates = new Set(textureUuids.map(uuid => this.rootAssetUuid(uuid))); + const retained = new Set(); + if (candidates.size === 0) return retained; + + const pending: unknown[] = [JSON.parse(await Service.Editor.querySceneSerializedData()) as unknown]; + while (pending.length > 0) { + const value = pending.pop(); + if (!value || typeof value !== 'object') continue; + const assetUuid = (value as { __uuid__?: unknown }).__uuid__; + if (typeof assetUuid === 'string') { + const rootUuid = this.rootAssetUuid(assetUuid); + if (candidates.has(rootUuid)) retained.add(rootUuid); + } + pending.push(...Object.values(value)); + } + return retained; + } + + private rootAssetUuid(uuid: string): string { + try { + const decompressed = (globalThis as any).EditorExtends?.UuidUtils?.decompressUUID?.(uuid); + if (typeof decompressed === 'string' && decompressed.length > 0) return decompressed.split('@', 1)[0]; + } catch { + // Invalid identifiers are left unchanged for the Host to reject conservatively. + } + return uuid.split('@', 1)[0]; + } + cancel(): Promise { return lightFXCoordinator.cancel('lightmap'); } diff --git a/src/core/scene/test/lightmap-result-recording.test.ts b/src/core/scene/test/lightmap-result-recording.test.ts index 5792ba2be..0f15de5f0 100644 --- a/src/core/scene/test/lightmap-result-recording.test.ts +++ b/src/core/scene/test/lightmap-result-recording.test.ts @@ -5,21 +5,25 @@ const mockBake = jest.fn(); const mockCommit = jest.fn(); const mockRollback = jest.fn(); const mockRemoveLightmapAssets = jest.fn(); +const mockQueryCapabilities = jest.fn(async (): Promise<{ lightmapAssetCleanupVersion?: 1 }> => ({ lightmapAssetCleanupVersion: 1 })); const mockUndo = { beginRecording: jest.fn(() => 'recording'), endRecording: jest.fn(async () => undefined), cancelRecording: jest.fn(), + clearHistory: jest.fn(), createCheckpoint: jest.fn(() => ({ commandId: 'recording', generation: 1 })), markSaved: jest.fn(), }; const mockSave = jest.fn(async () => undefined); +const mockQuerySceneSerializedData = jest.fn(async () => '[]'); jest.mock('cc', () => ({ director: { getScene: mockGetScene }, MeshRenderer: mockMeshRenderer, Terrain: mockTerrain })); jest.mock('../scene-process/service/core', () => ({ BaseService: class { broadcast() {} }, register: () => () => undefined, - Service: { Undo: mockUndo, Editor: { save: mockSave }, Engine: { repaintInEditMode: async () => undefined } }, + Service: { Undo: mockUndo, Editor: { save: mockSave, querySceneSerializedData: mockQuerySceneSerializedData }, Engine: { repaintInEditMode: async () => undefined } }, })); jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { bake: mockBake, commit: mockCommit, rollback: mockRollback, removeLightmapAssets: mockRemoveLightmapAssets } })); jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { + queryCapabilities: mockQueryCapabilities, reserveSceneOperation: async () => ({ transactionId: 'owner' }), releaseSceneOperation: async () => undefined, } })); jest.mock('../scene-process/service/baking/lightfx/settings', () => ({ createDefaultLightFXSettings: () => ({}) })); @@ -52,6 +56,8 @@ function fixture() { describe('Lightmap result recording targets', () => { beforeEach(() => { jest.clearAllMocks(); + mockQueryCapabilities.mockResolvedValue({ lightmapAssetCleanupVersion: 1 }); + mockQuerySceneSerializedData.mockResolvedValue('[]'); mockRemoveLightmapAssets.mockResolvedValue({ deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }); }); it.each([false, true])('records Mesh and Terrain components before scene flags for Bake (save=%s)', async saveScene => { @@ -78,7 +84,7 @@ describe('Lightmap result recording targets', () => { expect(mockSave).toHaveBeenCalledTimes(saveScene ? 1 : 0); expect(mockUndo.markSaved).not.toHaveBeenCalled(); }); - it('saves before exact deletion and discards only the Clear recording', async () => { + it('saves before exact deletion and resets all history before deleting assets', async () => { const f = fixture(); mockRemoveLightmapAssets.mockResolvedValueOnce({ deletedTextureUuids: ['old-texture'], retainedTextureUuids: ['shared'], failures: [{ uuid: 'failed', reason: 'busy' }], @@ -87,10 +93,45 @@ describe('Lightmap result recording targets', () => { clearedCount: 3, deletedAssetCount: 1, retainedAssetCount: 1, failedAssetCount: 1, }); expect(mockSave).toHaveBeenCalledTimes(1); - expect(mockUndo.cancelRecording).toHaveBeenCalledWith('recording'); + expect(mockUndo.clearHistory).toHaveBeenCalledTimes(1); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); expect(mockUndo.endRecording).not.toHaveBeenCalled(); expect(mockRemoveLightmapAssets).toHaveBeenCalledWith('scene', ['old-texture']); expect(mockSave.mock.invocationCallOrder[0]).toBeLessThan(mockRemoveLightmapAssets.mock.invocationCallOrder[0]); + expect(mockUndo.clearHistory.mock.invocationCallOrder[0]).toBeLessThan(mockRemoveLightmapAssets.mock.invocationCallOrder[0]); + }); + it('retains a generated texture still referenced elsewhere in the cleared scene', async () => { + const f = fixture(); + mockQuerySceneSerializedData.mockResolvedValueOnce(JSON.stringify([ + { __type__: 'cc.Component', unrelatedTexture: { __uuid__: 'old-texture@f9941' } }, + ])); + await expect(f.service.clearBake({ deleteAssets: true })).resolves.toEqual({ + clearedCount: 3, deletedAssetCount: 0, retainedAssetCount: 1, failedAssetCount: 0, + }); + expect(mockSave).toHaveBeenCalledTimes(1); + expect(mockUndo.clearHistory).toHaveBeenCalledTimes(1); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + }); + it('restores bindings without saving when the live scene reference check fails', async () => { + const f = fixture(); + mockQuerySceneSerializedData.mockRejectedValueOnce(new Error('serialization failed')); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('serialization failed'); + expect(mockSave).not.toHaveBeenCalled(); + expect(mockUndo.cancelRecording).toHaveBeenCalledWith('recording'); + expect(mockUndo.clearHistory).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + expect(f.model._updateLightmap).toHaveBeenLastCalledWith(f.oldTexture, 1, 2, 3, 4); + expect(f.terrain._updateLightmap).toHaveBeenLastCalledWith(1, f.oldTexture, 5, 6, 7, 8); + }); + it('does not restore only memory or delete assets when history reset notification fails after saving', async () => { + const f = fixture(); + mockUndo.clearHistory.mockImplementationOnce(() => { throw new Error('notification failed'); }); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('notification failed'); + expect(mockSave).toHaveBeenCalledTimes(1); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + expect(f.model._updateLightmap).toHaveBeenLastCalledWith(null, 0, 0, 0, 0); + expect(f.terrain._updateLightmap).toHaveBeenLastCalledWith(1, null, 0, 0, 0, 0); }); it('rejects deletion without saving before changing the scene', async () => { const f = fixture(); @@ -99,6 +140,15 @@ describe('Lightmap result recording targets', () => { expect(mockUndo.beginRecording).not.toHaveBeenCalled(); expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); }); + it('rejects a legacy cleanup host before changing the scene', async () => { + const f = fixture(); + mockQueryCapabilities.mockResolvedValueOnce({}); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('does not support exact Lightmap asset cleanup'); + expect(f.model._updateLightmap).not.toHaveBeenCalled(); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + }); it('does not delete assets when the required save is unconfirmed', async () => { const f = fixture(); mockSave.mockRejectedValueOnce(new Error('save response lost')); From 36c02f355a9bb8f50fedfea9e875648157666be5 Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 18:05:09 +0800 Subject: [PATCH 43/64] =?UTF-8?q?fix/=20=E4=BF=AE=E5=A4=8D=E5=85=89?= =?UTF-8?q?=E7=85=A7=E8=B4=B4=E5=9B=BE=E6=B8=85=E7=A9=BA=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E4=B8=8E=E6=97=A7=E7=BB=91=E5=AE=9A=E6=AE=8B?= =?UTF-8?q?=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/scene/lightfx-bake.ts | 2 +- src/core/scene/common/lightfx-bake.ts | 2 +- .../baking/lightfx/deleted-lightmap-assets.ts | 109 ++++++++++++++++ .../service/editors/scene-editor.ts | 2 + .../scene-process/service/lightmap-bake.ts | 22 +++- src/core/scene/scene-process/service/undo.ts | 5 +- .../undo/commands/command-utils-shared.ts | 4 + .../undo/commands/component-command-utils.ts | 3 +- .../commands/node-structure-command-utils.ts | 11 +- .../test/deleted-lightmap-assets.test.ts | 121 ++++++++++++++++++ .../scene/test/editor-close-options.test.ts | 19 +++ .../test/lightmap-result-recording.test.ts | 85 ++++++++++-- src/core/scene/test/undo-node-restore.test.ts | 12 ++ .../undo-node-structure-serialization.test.ts | 8 +- 14 files changed, 377 insertions(+), 28 deletions(-) create mode 100644 src/core/scene/scene-process/service/baking/lightfx/deleted-lightmap-assets.ts create mode 100644 src/core/scene/test/deleted-lightmap-assets.test.ts diff --git a/src/api/scene/lightfx-bake.ts b/src/api/scene/lightfx-bake.ts index 240d8ece7..2de49d13c 100644 --- a/src/api/scene/lightfx-bake.ts +++ b/src/api/scene/lightfx-bake.ts @@ -48,7 +48,7 @@ export class LightFXBakeApi { @tool('scene-clear-lightmap') @title('Clear baked lightmap') - @description('Unbind baked lightmaps and optionally delete unreferenced generated assets. Asset deletion saves the scene and clears its Undo/Redo history.') + @description('Unbind baked lightmaps and optionally delete unreferenced generated assets. Asset deletion saves the scene and prevents Undo/Redo from restoring pre-Clear baked results; unrelated edit history is preserved.') @result(SchemaClearCountResult) clearLightmap(@param(SchemaLightmapClearOptions) options: { saveScene?: boolean; deleteAssets?: boolean }): Promise; bake(options: ILightmapBakeOptions): Promise; queryBakeInfo(): Promise; - /** Asset deletion saves the scene and clears all Scene Undo/Redo history before removing textures. */ + /** Asset deletion saves the scene; unrelated history is kept, but pre-Clear baked results cannot be restored. */ clearBake(options?: { saveScene?: boolean; deleteAssets?: boolean }): Promise; /** Cancels only this Scene's lightmap bake after native ownership is acquired; otherwise a no-op. */ cancel(): Promise; diff --git a/src/core/scene/scene-process/service/baking/lightfx/deleted-lightmap-assets.ts b/src/core/scene/scene-process/service/baking/lightfx/deleted-lightmap-assets.ts new file mode 100644 index 000000000..7ad766101 --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/deleted-lightmap-assets.ts @@ -0,0 +1,109 @@ +/** Scene-local Clear epochs and deleted asset barriers; no persistent or global Undo state. */ +export class DeletedLightmapAssets { + private readonly scenes = new WeakMap>(); + private readonly epochs = new WeakMap(); + private readonly snapshots = new WeakMap(); + + constructor(private readonly normalize: (uuid: string) => string = rootLightmapAssetUuid) {} + + /** A soft reload replaces the Scene but can retain its Undo history. */ + transfer(scene: object, replacement: object): void { + const blocked = this.scenes.get(scene); + if (blocked) this.scenes.set(replacement, blocked); + const epoch = this.epochs.get(scene); + if (epoch) this.epochs.set(replacement, epoch); + } + + /** Tag the actual history object, without adding fields to scene or asset serialization. */ + capture(scene: object | null | undefined, snapshot: T): T { + if (scene && snapshot && typeof snapshot === 'object') { + this.snapshots.set(snapshot, this.epochs.get(scene)?.value ?? 0); + } + return snapshot; + } + + /** Clear invalidates all older Lightmap results, not ordinary edits or probe coefficients. */ + clearResults(scene: object): void { + const epoch = this.epochs.get(scene) ?? { value: 0 }; + epoch.value++; + this.epochs.set(scene, epoch); + } + + /** Protect in-flight deletes too; an unconfirmed response must not revive possibly deleted assets. */ + begin(scene: object, uuids: readonly string[]): (deleted: readonly string[]) => void { + const blocked = this.scenes.get(scene) ?? new Set(); + this.scenes.set(scene, blocked); + const added = uuids.map(this.normalize).filter(uuid => !blocked.has(uuid)); + added.forEach(uuid => blocked.add(uuid)); + return deleted => { + const confirmed = new Set(deleted.map(this.normalize)); + added.forEach(uuid => { if (!confirmed.has(uuid)) blocked.delete(uuid); }); + if (blocked.size === 0) this.scenes.delete(scene); + }; + } + + /** Copy on write: clear stale baked fields and deleted references, preserving unrelated history. */ + filter(scene: object | null | undefined, snapshot: T, format: 'dump' | 'serialized', history: object = snapshot as object): T { + const blocked = scene && this.scenes.get(scene); + const stale = (this.snapshots.get(history) ?? 0) < (scene ? this.epochs.get(scene)?.value ?? 0 : 0); + if (!blocked?.size && !stale) return snapshot; + const isBlocked = (uuid: unknown): boolean => typeof uuid === 'string' && !!blocked?.has(this.normalize(uuid)); + const visit = (value: unknown): unknown => { + if (!value || typeof value !== 'object') return value; + if (Array.isArray(value)) { + const items = value.map(visit); + return items.some((item, i) => item !== value[i]) ? items : value; + } + const record = value as Record; + if (format === 'serialized' && isBlocked(record.__uuid__)) return null; + if (format === 'dump' && typeof record.type === 'string' && isBlocked(record.value?.uuid)) { + return { ...record, value: { ...record.value, uuid: '' } }; + } + let result = record; + for (const [key, child] of Object.entries(record)) { + const filtered = visit(child); + if (filtered !== child) { + if (result === record) result = { ...record }; + result[key] = filtered; + } + } + const type = format === 'dump' ? record.type : record.__type__; + const fields = format === 'dump' ? record.value : record; + const textureUuid = format === 'dump' ? fields?.texture?.value?.uuid : fields?.texture?.__uuid__; + if ((type === 'cc.ModelBakeSettings' || type === 'cc.TerrainBlockLightmapInfo') && (stale || isBlocked(textureUuid))) { + const cleared = { ...(format === 'dump' ? result.value : result) }; + if (cleared.texture) { + cleared.texture = format === 'dump' + ? { ...cleared.texture, value: { ...cleared.texture.value, uuid: '' } } : null; + } + if (type === 'cc.ModelBakeSettings' && cleared.uvParam) { + cleared.uvParam = format === 'dump' + ? { ...cleared.uvParam, value: { ...cleared.uvParam.value, x: 0, y: 0, z: 0, w: 0 } } + : { ...cleared.uvParam, x: 0, y: 0, z: 0, w: 0 }; + } else if (type === 'cc.TerrainBlockLightmapInfo') { + for (const key of ['UOff', 'VOff', 'UScale', 'VScale']) { + if (key in cleared) cleared[key] = format === 'dump' ? { ...cleared[key], value: 0 } : 0; + } + } + result = format === 'dump' ? { ...result, value: cleared } : cleared; + } + if (stale && type === 'cc.SceneGlobals') { + const cleared = { ...(format === 'dump' ? result.value : result) }; + for (const key of ['bakedWithHighpLightmap', 'bakedWithStationaryMainLight']) { + if (key in cleared) cleared[key] = format === 'dump' ? { ...cleared[key], value: false } : false; + } + result = format === 'dump' ? { ...result, value: cleared } : cleared; + } + return result; + }; + return visit(snapshot) as T; + } +} + +export function rootLightmapAssetUuid(uuid: string): string { + const root = uuid.split('@', 1)[0]; + const decompress = (globalThis as any).EditorExtends?.UuidUtils?.decompressUUID; + return typeof decompress === 'function' ? decompress(root) : root; +} + +export const deletedLightmapAssets = new DeletedLightmapAssets(); diff --git a/src/core/scene/scene-process/service/editors/scene-editor.ts b/src/core/scene/scene-process/service/editors/scene-editor.ts index 48b6e3d54..bbc3a829a 100644 --- a/src/core/scene/scene-process/service/editors/scene-editor.ts +++ b/src/core/scene/scene-process/service/editors/scene-editor.ts @@ -6,6 +6,7 @@ import { BaseEditor } from './base-editor'; import type { IAssetInfo } from '../../../../assets/@types/public'; import { editorPrefabUtils } from '../prefab/prefab-editor-utils'; +import { deletedLightmapAssets } from '../baking/lightfx/deleted-lightmap-assets'; /** * SceneEditor - 场景编辑器 @@ -98,6 +99,7 @@ export class SceneEditor extends BaseEditor { const prefabUUIDMap = editorPrefabUtils.storePrefabUUID(scene); const serializeJSON = sceneUtils.serialize(scene); const sceneAfterLoad = await sceneUtils.runSceneImmediateByJson(serializeJSON); + deletedLightmapAssets.transfer(scene, sceneAfterLoad); editorPrefabUtils.restorePrefabUUID(sceneAfterLoad, prefabUUIDMap); this.entity.instance = sceneAfterLoad; return this.encode(undefined, this._lastOpenOptions); diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index 90db61801..03f03e40e 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -10,6 +10,7 @@ import { lightFXBakeHost } from './baking/lightfx/host'; import { createDefaultLightFXSettings } from './baking/lightfx/settings'; import { lightFXSceneOperation } from './baking/lightfx/scene-operation'; import { finishSavedLightFXRecording, LightFXResultRetainedError } from './baking/lightfx/saved-recording'; +import { deletedLightmapAssets } from './baking/lightfx/deleted-lightmap-assets'; import { BaseService, register, Service } from './core'; import { loadPreviewAsset } from './preview/asset-reload'; @@ -76,14 +77,19 @@ export class LightmapBakeService extends BaseService impleme // An unconfirmed commit can leave an orphan version, never a dangling scene binding. await lightFXCoordinator.commit(output.operationId); nativeCommitted = true; - const previousBindings = this.snapshotBindings(output); + const previousBindings = this.snapshotSceneBindings(scene); + const affectedBindings = [...previousBindings, ...this.snapshotBindings(output)]; const previousHighp = (scene.globals as any).bakedWithHighpLightmap; const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; // Scene recordings do not recursively capture child components. // Keep the flags last, after restoring each affected result binding. - const targets = [...new Set([...output.models, ...output.terrains].map(component => component.uuid)), scene.uuid]; + const targets = [...new Set([...output.models, ...output.terrains, ...previousBindings.map(binding => binding.target)] + .map(component => component.uuid)), scene.uuid]; const undo = Service.Undo.beginRecording(targets, { label: 'Bake lightmap' }); try { + // A successful bake replaces the complete result, including disabled objects + // that were excluded from this export but still have older bindings. + this.clearBindings(previousBindings); this.applyBakeResult(output, textures); (scene.globals as any).bakedWithHighpLightmap = settings.highp; (scene.globals as any).bakedWithStationaryMainLight = output.stationaryMainLight; @@ -92,7 +98,7 @@ export class LightmapBakeService extends BaseService impleme options.saveScene !== false ? () => Service.Editor.save({}) : undefined); } catch (error) { if (error instanceof LightFXResultRetainedError) throw error; - this.restoreBindings(previousBindings); + this.restoreBindings(affectedBindings); (scene.globals as any).bakedWithHighpLightmap = previousHighp; (scene.globals as any).bakedWithStationaryMainLight = previousStationary; Service.Undo.cancelRecording(undo); @@ -215,14 +221,16 @@ export class LightmapBakeService extends BaseService impleme } if (options.deleteAssets === true) { - // This is intentionally outside the rollback block: the cleared scene is already saved, - // so a notification failure must not restore only the in-memory bindings. Asset deletion - // cannot participate in Scene Undo; reset the entire stack before removing any texture. - Service.Undo.clearHistory(); + // Keep earlier edits, but invalidate all pre-Clear baked results. This stays outside + // rollback: a notification failure must not restore only the already-saved memory state. + deletedLightmapAssets.clearResults(scene); + Service.Undo.cancelRecording(undo); const deletableTextureUuids = textureUuids.filter(uuid => !retainedSceneTextureUuids.has(uuid)); + const finishDeletion = deletedLightmapAssets.begin(scene, deletableTextureUuids); const result = deletableTextureUuids.length > 0 ? await lightFXCoordinator.removeLightmapAssets(scene.uuid, deletableTextureUuids) : { deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }; + finishDeletion(result.deletedTextureUuids); return { clearedCount: bindings.length, deletedAssetCount: result.deletedTextureUuids.length, diff --git a/src/core/scene/scene-process/service/undo.ts b/src/core/scene/scene-process/service/undo.ts index b89a9f981..c7169c7cb 100644 --- a/src/core/scene/scene-process/service/undo.ts +++ b/src/core/scene/scene-process/service/undo.ts @@ -8,6 +8,7 @@ import type { ISnapshotAdapter } from './undo/commands/snapshot-command'; import { restoreComponentSnapshotDump, restoreNodeSnapshotDump, snapshotMapsEqual } from './undo/commands/command-utils-shared'; import dumpUtil from './dump'; import { withLightProbeTransformScenes } from './scene/light-probe-transform'; +import { deletedLightmapAssets } from './baking/lightfx/deleted-lightmap-assets'; interface IRecordingComponentSnapshot { uuid: string; @@ -251,7 +252,7 @@ export class UndoService extends BaseService implements IUndoServic kind: 'node', uuid: node.uuid, path: this._getNodePath(node), - dump: this._cloneDump(dumpUtil.dumpNode(node, { includeComponents: false })), + dump: deletedLightmapAssets.capture(node.scene, this._cloneDump(dumpUtil.dumpNode(node, { includeComponents: false }))), components: node.components .map(component => this._captureComponentSnapshot(component as Component)) .filter((snapshot): snapshot is IRecordingComponentSnapshot => !!snapshot), @@ -270,7 +271,7 @@ export class UndoService extends BaseService implements IUndoServic nodePath: this._getNodePath(component.node), index: component.node.components.indexOf(component), type: this._getComponentType(component), - dump: this._cloneDump(dumpUtil.dumpComponent(component)), + dump: deletedLightmapAssets.capture(component.node.scene, this._cloneDump(dumpUtil.dumpComponent(component))), }; } diff --git a/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts b/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts index bd0b48714..8d4a9de36 100644 --- a/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts +++ b/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts @@ -2,6 +2,7 @@ import { Component, Node } from 'cc'; import type { IUndoCommandMeta, IUndoRedoResult } from '../../../../common'; import { restoreTerrainLightmapBindings } from '../../dump/terrain-lightmap-restore'; import { restoreLightProbeGroupCache } from '../../dump/light-probe-group-restore'; +import { deletedLightmapAssets } from '../../baking/lightfx/deleted-lightmap-assets'; export function createUndoId(prefix: string): string { try { @@ -91,6 +92,8 @@ export async function restoreNodeSnapshotDump( return; } + dump = deletedLightmapAssets.filter(node.scene, dump, 'dump'); + if (dump.name && dump.name.value !== node.name) { const name = dump.name.value as string; if (options.updateNodeName) { @@ -137,6 +140,7 @@ export async function restoreComponentSnapshotDump( if (!dump?.value) { return; } + dump = deletedLightmapAssets.filter(component.node?.scene, dump, 'dump'); const { default: dumpUtil } = await import('../../dump'); await dumpUtil.restoreComponentSnapshotProperties(component, dump); (component as any).onRestore?.(); diff --git a/src/core/scene/scene-process/service/undo/commands/component-command-utils.ts b/src/core/scene/scene-process/service/undo/commands/component-command-utils.ts index 948aa9885..f05d73e6c 100644 --- a/src/core/scene/scene-process/service/undo/commands/component-command-utils.ts +++ b/src/core/scene/scene-process/service/undo/commands/component-command-utils.ts @@ -2,6 +2,7 @@ import { Component, Node } from 'cc'; import { EventSourceType, NodeEventType, type IUndoCommandMeta, type IUndoRedoResult } from '../../../../common'; import compMgr from '../../component/index'; import dumpUtil from '../../dump'; +import { deletedLightmapAssets } from '../../baking/lightfx/deleted-lightmap-assets'; import { createUndoId, success, @@ -52,7 +53,7 @@ export function captureComponentStructureSnapshot(component: Component): ICompon nodePath: getNodePath(component.node), index: component.node.components.indexOf(component), type: getComponentType(component), - dump: cloneDump(dump), + dump: deletedLightmapAssets.capture(component.node.scene, cloneDump(dump)), }; } diff --git a/src/core/scene/scene-process/service/undo/commands/node-structure-command-utils.ts b/src/core/scene/scene-process/service/undo/commands/node-structure-command-utils.ts index a8a9e63c9..1e8808ac7 100644 --- a/src/core/scene/scene-process/service/undo/commands/node-structure-command-utils.ts +++ b/src/core/scene/scene-process/service/undo/commands/node-structure-command-utils.ts @@ -4,6 +4,7 @@ import nodeMgr from '../../node/index'; import { editorPrefabUtils } from '../../prefab/prefab-editor-utils'; import { nodeOperation } from '../../prefab/node'; import { sceneUtils } from '../../scene/utils'; +import { deletedLightmapAssets } from '../../baking/lightfx/deleted-lightmap-assets'; import { createUndoId, success, @@ -75,7 +76,7 @@ export function captureNodeStructureSnapshot( return null; } - return { + return deletedLightmapAssets.capture(node.scene, { uuid: node.uuid, path: getNodePath(node) || fallbackPath, parentUuid: parent?.uuid ?? null, @@ -84,7 +85,7 @@ export function captureNodeStructureSnapshot( serializedJson, prefabAssetUuid: getPrefabAssetUuid(node), uuidTree: captureUuidTree(node), - }; + }); } function serializeNodeStructure(node: Node, serialization: NodeStructureSerialization): string { @@ -112,7 +113,7 @@ export async function restoreNodeStructureSnapshot(snapshot: INodeStructureSnaps return failure(meta, `Parent node not found: ${snapshot.parentPath || snapshot.parentUuid || '/'}`); } - const restoredNode = await deserializeNode(snapshot); + const restoredNode = await deserializeNode(snapshot, parent.scene); if (!restoredNode) { return failure(meta, `Failed to deserialize node: ${snapshot.path || snapshot.uuid}`); } @@ -295,7 +296,7 @@ function unregisterNodeTree(node: Node): void { } } -function deserializeNode(snapshot: INodeStructureSnapshot): Promise { +function deserializeNode(snapshot: INodeStructureSnapshot, scene: Node | null): Promise { return new Promise((resolve) => { try { const loadWithJson = (cc as any).assetManager?.loadWithJson; @@ -304,7 +305,7 @@ function deserializeNode(snapshot: INodeStructureSnapshot): Promise return; } - const json = JSON.parse(snapshot.serializedJson); + const json = deletedLightmapAssets.filter(scene, JSON.parse(snapshot.serializedJson), 'serialized', snapshot); loadWithJson.call((cc as any).assetManager, json, null, (error: Error | null, asset: any) => { if (error) { resolve(null); diff --git a/src/core/scene/test/deleted-lightmap-assets.test.ts b/src/core/scene/test/deleted-lightmap-assets.test.ts new file mode 100644 index 000000000..d05f69399 --- /dev/null +++ b/src/core/scene/test/deleted-lightmap-assets.test.ts @@ -0,0 +1,121 @@ +import { DeletedLightmapAssets } from '../scene-process/service/baking/lightfx/deleted-lightmap-assets'; +import { SceneUndoManager } from '../scene-process/service/undo/scene-undo-manager'; + +const uv = { x: 1, y: 2, z: 3, w: 4 }; +const mesh = (uuid: string) => ({ type: 'cc.ModelBakeSettings', value: { + texture: { type: 'cc.Texture2D', value: { uuid } }, uvParam: { type: 'cc.Vec4', value: { ...uv } }, bakeable: { value: true }, +} }); +const terrain = (uuid: string) => ({ type: 'cc.TerrainBlockLightmapInfo', value: { + texture: { type: 'cc.Texture2D', value: { uuid } }, + UOff: { value: 1 }, VOff: { value: 2 }, UScale: { value: 3 }, VScale: { value: 4 }, +} }); + +describe('Deleted Lightmap snapshot references', () => { + it('keeps ordinary edits but invalidates both Bake A and B at Clear, while allowing later Bake C history', async () => { + const scene = {}, deleted = new DeletedLightmapAssets(); + let state = { position: 0, mesh: mesh('A'), terrain: [terrain('A'), terrain('A')], sh: [1, 2, 3] }; + const manager = new SceneUndoManager({ snapshotAdapter: { + capture: () => new Map([['state', deleted.capture(scene, structuredClone(state))]]), + equals: (a, b) => JSON.stringify([...a]) === JSON.stringify([...b]), + apply: snapshots => { state = deleted.filter(scene, snapshots.get('state'), 'dump'); return { success: true }; }, + } }); + const bake = manager.beginRecording(['mesh']); + state.mesh = mesh('B@6c48a'); state.terrain = [terrain('B@6c48a'), terrain('B@6c48a')]; + await manager.endRecording(bake); + const move = manager.beginRecording(['node']); + state.position = 10; + await manager.endRecording(move); + const clear = manager.beginRecording(['mesh']); + state.mesh = mesh(''); state.terrain = [terrain(''), terrain('')]; + manager.markSaved(); manager.cancelRecording(clear); + deleted.clearResults(scene); + deleted.begin(scene, ['B'])(['B']); + + expect((await manager.undo()).success).toBe(true); + expect({ x: state.position, texture: state.mesh.value.texture.value.uuid, uv: state.mesh.value.uvParam.value, + terrain: state.terrain.map(block => [block.value.texture.value.uuid, block.value.UScale.value]), sh: state.sh }) + .toEqual({ x: 0, texture: '', uv: { x: 0, y: 0, z: 0, w: 0 }, terrain: [['', 0], ['', 0]], sh: [1, 2, 3] }); + await manager.undo(); + expect(state.mesh.value.texture.value.uuid).toBe(''); + await manager.redo(); await manager.redo(); + expect({ x: state.position, texture: state.mesh.value.texture.value.uuid, sh: state.sh }) + .toEqual({ x: 10, texture: '', sh: [1, 2, 3] }); + expect(manager.canRedo()).toBe(false); + const nextBake = manager.beginRecording(['mesh']); + state.mesh = mesh('C'); + await manager.endRecording(nextBake); + await manager.undo(); + expect(state.mesh.value.texture.value.uuid).toBe(''); + await manager.redo(); + expect(state.mesh).toEqual(mesh('C')); + }); + + it('filters serialized node reconstruction without mutating the historical JSON', () => { + const scene = {}, deleted = new DeletedLightmapAssets(); + const json = [ + { __type__: 'cc.Node', _lpos: { x: 10 } }, + { __type__: 'cc.ModelBakeSettings', texture: { __uuid__: 'B@6c48a' }, uvParam: { ...uv } }, + { __type__: 'cc.TerrainBlockLightmapInfo', texture: { __uuid__: 'B' }, UOff: 1, VOff: 2, UScale: 3, VScale: 4 }, + { __type__: 'Custom', texture: { __uuid__: 'B' }, other: { __uuid__: 'A' } }, + ]; + const original = structuredClone(json); + deleted.begin(scene, ['B'])(['B']); + expect(deleted.filter(scene, json, 'serialized')).toEqual([ + json[0], + { __type__: 'cc.ModelBakeSettings', texture: null, uvParam: { x: 0, y: 0, z: 0, w: 0 } }, + { __type__: 'cc.TerrainBlockLightmapInfo', texture: null, UOff: 0, VOff: 0, UScale: 0, VScale: 0 }, + { __type__: 'Custom', texture: null, other: { __uuid__: 'A' } }, + ]); + expect(json).toEqual(original); + }); + + it('normalizes compressed/subasset UUIDs, isolates Scene instances and releases retained assets', () => { + const deleted = new DeletedLightmapAssets(uuid => uuid.split('@')[0].replace('short', 'long')); + const scene = {}, snapshot = mesh('short@6c48a'); + const finish = deleted.begin(scene, ['long']); + expect(deleted.filter(scene, snapshot, 'dump').value.texture.value.uuid).toBe(''); + expect(deleted.filter({}, snapshot, 'dump')).toBe(snapshot); + finish([]); + expect(deleted.filter(scene, snapshot, 'dump')).toBe(snapshot); + }); + + it('does not release a previously deleted asset when another deletion is retained', () => { + const deleted = new DeletedLightmapAssets(), scene = {}; + deleted.begin(scene, ['B'])(['B']); + deleted.begin(scene, ['B', 'C'])([]); + expect(deleted.filter(scene, mesh('B'), 'dump').value.texture.value.uuid).toBe(''); + expect(deleted.filter(scene, mesh('C'), 'dump')).toEqual(mesh('C')); + }); + + it('transfers in-flight protection across a Scene replacement without affecting another scene', () => { + const deleted = new DeletedLightmapAssets(), scene = {}, replacement = {}; + const finish = deleted.begin(scene, ['B', 'C']); + deleted.transfer(scene, replacement); + expect(deleted.filter(replacement, mesh('B'), 'dump').value.texture.value.uuid).toBe(''); + finish(['B']); + expect(deleted.filter(replacement, mesh('B'), 'dump').value.texture.value.uuid).toBe(''); + expect(deleted.filter(replacement, mesh('C'), 'dump')).toEqual(mesh('C')); + expect(deleted.filter({}, mesh('B'), 'dump')).toEqual(mesh('B')); + }); + + it('clears only pre-Clear baked fields, preserving external references and SH across soft reload', () => { + const deleted = new DeletedLightmapAssets(), scene = {}, replacement = {}; + const before = deleted.capture(scene, [ + { __type__: 'cc.ModelBakeSettings', texture: { __uuid__: 'retained-A' }, uvParam: { ...uv } }, + { __type__: 'cc.SceneGlobals', bakedWithHighpLightmap: true, bakedWithStationaryMainLight: true, sh: [1, 2] }, + { __type__: 'Custom', texture: { __uuid__: 'retained-A' } }, + ]); + deleted.clearResults(scene); + deleted.transfer(scene, replacement); + expect(deleted.filter(replacement, before, 'serialized')).toEqual([ + { __type__: 'cc.ModelBakeSettings', texture: null, uvParam: { x: 0, y: 0, z: 0, w: 0 } }, + { __type__: 'cc.SceneGlobals', bakedWithHighpLightmap: false, bakedWithStationaryMainLight: false, sh: [1, 2] }, + before[2], + ]); + const after = deleted.capture(replacement, mesh('C')); + expect(deleted.filter(replacement, after, 'dump')).toBe(after); + deleted.clearResults(replacement); + expect(deleted.filter(replacement, after, 'dump').value.texture.value.uuid).toBe(''); + expect(before[0].texture).toEqual({ __uuid__: 'retained-A' }); + }); +}); diff --git a/src/core/scene/test/editor-close-options.test.ts b/src/core/scene/test/editor-close-options.test.ts index 019bd25ec..7d13523f0 100644 --- a/src/core/scene/test/editor-close-options.test.ts +++ b/src/core/scene/test/editor-close-options.test.ts @@ -20,6 +20,7 @@ jest.mock('../scene-process/service/scene/utils', () => ({ generateNodeDump: jest.fn(), loadAny: jest.fn(), runScene: jest.fn(async () => undefined), + runSceneImmediateByJson: jest.fn(), serialize: jest.fn(), }, })); @@ -51,6 +52,7 @@ import { SceneEditor } from '../scene-process/service/editors/scene-editor'; import { PrefabEditor } from '../scene-process/service/editors/prefab-editor'; import { sceneUtils } from '../scene-process/service/scene/utils'; import { editorPrefabUtils } from '../scene-process/service/prefab/prefab-editor-utils'; +import { deletedLightmapAssets } from '../scene-process/service/baking/lightfx/deleted-lightmap-assets'; type CloseableEditor = SceneEditor | PrefabEditor; @@ -90,6 +92,23 @@ describe('Editor close options', () => { await expectCloseSaveCalls(new SceneEditor(), { save: false }, 0); }); + it('scene reload carries deleted Lightmap protection to the replacement Scene', async () => { + const editor = new SceneEditor(); + setOpen(editor); + const scene = editor.getRootNode()!; + const replacement = {}; + (sceneUtils.runSceneImmediateByJson as jest.Mock).mockResolvedValue(replacement); + (sceneUtils.generateNodeDump as jest.Mock).mockReturnValue({}); + deletedLightmapAssets.begin(scene, ['deleted-texture'])(['deleted-texture']); + + await editor.reload(); + + expect(editor.getRootNode()).toBe(replacement); + expect(deletedLightmapAssets.filter(replacement, { __uuid__: 'deleted-texture' }, 'serialized')).toBeNull(); + expect(deletedLightmapAssets.filter({}, { __uuid__: 'deleted-texture' }, 'serialized')) + .toEqual({ __uuid__: 'deleted-texture' }); + }); + it('prefab close saves by default and can skip save', async () => { await expectCloseSaveCalls(new PrefabEditor(), undefined, 1); await expectCloseSaveCalls(new PrefabEditor(), { save: false }, 0); diff --git a/src/core/scene/test/lightmap-result-recording.test.ts b/src/core/scene/test/lightmap-result-recording.test.ts index 0f15de5f0..a78ec526b 100644 --- a/src/core/scene/test/lightmap-result-recording.test.ts +++ b/src/core/scene/test/lightmap-result-recording.test.ts @@ -30,6 +30,7 @@ jest.mock('../scene-process/service/baking/lightfx/settings', () => ({ createDef jest.mock('../scene-process/service/preview/asset-reload', () => ({ loadPreviewAsset: jest.fn() })); jest.mock('../scene-process/rpc', () => ({ Rpc: { getInstance: jest.fn() } })); import { LightmapBakeService } from '../scene-process/service/lightmap-bake'; +import { deletedLightmapAssets } from '../scene-process/service/baking/lightfx/deleted-lightmap-assets'; function fixture() { const oldTexture = { uuid: 'old-texture' }; @@ -50,7 +51,7 @@ function fixture() { meshes: [{ id: 0, index: 0, offset: [0.1, 0.2], scale: [0.3, 0.4] }], terrains: [{ id: 0, index: 0, blockId: 1, offset: [0.5, 0.6], scale: [0.7, 0.8] }], } }); - return { service, model, terrain, texture, oldTexture }; + return { service, scene, model, terrain, texture, oldTexture }; } describe('Lightmap result recording targets', () => { @@ -84,7 +85,7 @@ describe('Lightmap result recording targets', () => { expect(mockSave).toHaveBeenCalledTimes(saveScene ? 1 : 0); expect(mockUndo.markSaved).not.toHaveBeenCalled(); }); - it('saves before exact deletion and resets all history before deleting assets', async () => { + it('saves before exact deletion and cancels only the Clear recording', async () => { const f = fixture(); mockRemoveLightmapAssets.mockResolvedValueOnce({ deletedTextureUuids: ['old-texture'], retainedTextureUuids: ['shared'], failures: [{ uuid: 'failed', reason: 'busy' }], @@ -93,12 +94,12 @@ describe('Lightmap result recording targets', () => { clearedCount: 3, deletedAssetCount: 1, retainedAssetCount: 1, failedAssetCount: 1, }); expect(mockSave).toHaveBeenCalledTimes(1); - expect(mockUndo.clearHistory).toHaveBeenCalledTimes(1); - expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(mockUndo.clearHistory).not.toHaveBeenCalled(); + expect(mockUndo.cancelRecording).toHaveBeenCalledWith('recording'); expect(mockUndo.endRecording).not.toHaveBeenCalled(); expect(mockRemoveLightmapAssets).toHaveBeenCalledWith('scene', ['old-texture']); expect(mockSave.mock.invocationCallOrder[0]).toBeLessThan(mockRemoveLightmapAssets.mock.invocationCallOrder[0]); - expect(mockUndo.clearHistory.mock.invocationCallOrder[0]).toBeLessThan(mockRemoveLightmapAssets.mock.invocationCallOrder[0]); + expect(mockUndo.cancelRecording.mock.invocationCallOrder[0]).toBeLessThan(mockRemoveLightmapAssets.mock.invocationCallOrder[0]); }); it('retains a generated texture still referenced elsewhere in the cleared scene', async () => { const f = fixture(); @@ -109,7 +110,7 @@ describe('Lightmap result recording targets', () => { clearedCount: 3, deletedAssetCount: 0, retainedAssetCount: 1, failedAssetCount: 0, }); expect(mockSave).toHaveBeenCalledTimes(1); - expect(mockUndo.clearHistory).toHaveBeenCalledTimes(1); + expect(mockUndo.clearHistory).not.toHaveBeenCalled(); expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); }); it('restores bindings without saving when the live scene reference check fails', async () => { @@ -123,16 +124,82 @@ describe('Lightmap result recording targets', () => { expect(f.model._updateLightmap).toHaveBeenLastCalledWith(f.oldTexture, 1, 2, 3, 4); expect(f.terrain._updateLightmap).toHaveBeenLastCalledWith(1, f.oldTexture, 5, 6, 7, 8); }); - it('does not restore only memory or delete assets when history reset notification fails after saving', async () => { + it('does not restore only memory or delete assets when recording cancellation fails after saving', async () => { const f = fixture(); - mockUndo.clearHistory.mockImplementationOnce(() => { throw new Error('notification failed'); }); + mockUndo.cancelRecording.mockImplementationOnce(() => { throw new Error('notification failed'); }); await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('notification failed'); expect(mockSave).toHaveBeenCalledTimes(1); - expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(mockUndo.cancelRecording).toHaveBeenCalledTimes(1); expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); expect(f.model._updateLightmap).toHaveBeenLastCalledWith(null, 0, 0, 0, 0); expect(f.terrain._updateLightmap).toHaveBeenLastCalledWith(1, null, 0, 0, 0, 0); }); + it.each(['deleted', 'retained', 'failed', 'unknown'])('protects in-flight deletes and settles %s results without clearing history', async outcome => { + const f = fixture(); + const snapshot = { type: 'cc.Texture2D', value: { uuid: 'old-texture@6c48a' } }; + mockRemoveLightmapAssets.mockImplementationOnce(async () => { + expect(deletedLightmapAssets.filter(f.scene, snapshot, 'dump').value.uuid).toBe(''); + if (outcome === 'unknown') throw new Error('response lost'); + return { + deletedTextureUuids: outcome === 'deleted' ? ['old-texture'] : [], + retainedTextureUuids: outcome === 'retained' ? ['old-texture'] : [], + failures: outcome === 'failed' ? [{ uuid: 'old-texture', reason: 'busy' }] : [], + }; + }); + if (outcome === 'unknown') await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('response lost'); + else await f.service.clearBake({ deleteAssets: true }); + expect(deletedLightmapAssets.filter(f.scene, snapshot, 'dump').value.uuid) + .toBe(outcome === 'deleted' || outcome === 'unknown' ? '' : snapshot.value.uuid); + expect(mockUndo.clearHistory).not.toHaveBeenCalled(); + }); + it('keeps history when there are no texture candidates', async () => { + const f = fixture(); + f.model.bakeSettings.texture = null as any; + f.terrain._lightmapInfos = []; + await f.service.clearBake({ deleteAssets: true }); + expect(mockUndo.clearHistory).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + }); + it('replaces excluded objects old bindings, recording them for normal rebake Undo', async () => { + const f = fixture(); + mockBake.mockResolvedValueOnce({ models: [f.model], terrains: [], operationId: 'operation', stationaryMainLight: false, + textureUrls: [], result: { meshes: [{ id: 0, index: 0, offset: [0, 0], scale: [1, 1] }], terrains: [] } }); + await f.service.bake({ saveScene: false }); + expect(mockUndo.beginRecording).toHaveBeenCalledWith(['mesh', 'terrain', 'scene'], { label: 'Bake lightmap' }); + expect(f.terrain._updateLightmap.mock.calls).toEqual([[0, null, 0, 0, 0, 0], [1, null, 0, 0, 0, 0]]); + expect(f.model._updateLightmap).toHaveBeenLastCalledWith(f.texture, 0, 0, 1, 1); + }); + it('establishes a Clear history barrier even when current results were already unbound', async () => { + const f = fixture(); + const old = deletedLightmapAssets.capture(f.scene, { type: 'cc.ModelBakeSettings', value: { + texture: { type: 'cc.Texture2D', value: { uuid: 'old-A' } }, + } }); + f.model.bakeSettings.texture = null as any; + f.terrain._lightmapInfos = []; + await f.service.clearBake({ deleteAssets: true }); + expect(deletedLightmapAssets.filter(f.scene, old, 'dump').value.texture.value.uuid).toBe(''); + }); + it('does not invalidate history if Clear cannot save the scene', async () => { + const f = fixture(); + const old = deletedLightmapAssets.capture(f.scene, { type: 'cc.ModelBakeSettings', value: { + texture: { type: 'cc.Texture2D', value: { uuid: 'old-A' } }, + } }); + mockSave.mockRejectedValueOnce(new Error('save failed')); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow(); + expect(deletedLightmapAssets.filter(f.scene, old, 'dump')).toBe(old); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + }); + it('restores excluded objects old bindings if applying the new Bake fails', async () => { + const f = fixture(); + mockBake.mockResolvedValueOnce({ models: [f.model], terrains: [], operationId: 'operation', stationaryMainLight: false, + textureUrls: [], result: { meshes: [{ id: 0, index: 0, offset: [0, 0], scale: [1, 1] }], terrains: [] } }); + f.model._updateLightmap.mockImplementationOnce(() => {}).mockImplementationOnce(() => { throw new Error('apply failed'); }); + await expect(f.service.bake({ saveScene: false })).rejects.toThrow('apply failed'); + expect(f.terrain._updateLightmap.mock.calls.slice(-2)).toEqual([ + [0, f.oldTexture, 1, 2, 3, 4], [1, f.oldTexture, 5, 6, 7, 8], + ]); + expect(mockUndo.cancelRecording).toHaveBeenCalledWith('recording'); + }); it('rejects deletion without saving before changing the scene', async () => { const f = fixture(); await expect(f.service.clearBake({ saveScene: false, deleteAssets: true })).rejects.toThrow('deleteAssets requires saveScene'); diff --git a/src/core/scene/test/undo-node-restore.test.ts b/src/core/scene/test/undo-node-restore.test.ts index 1401bcd96..6a218cf0a 100644 --- a/src/core/scene/test/undo-node-restore.test.ts +++ b/src/core/scene/test/undo-node-restore.test.ts @@ -6,6 +6,7 @@ import { NODE_SNAPSHOT_RESTORE_PROPERTY_PATHS, COMPONENT_SNAPSHOT_RESTORE_SKIP_KEYS, } from '../scene-process/service/dump/restore-policy'; +import { deletedLightmapAssets } from '../scene-process/service/baking/lightfx/deleted-lightmap-assets'; // 模拟 dump 模块,让 restoreNodeSnapshotDump / restoreComponentSnapshotDump // 可以调用 dumpUtil 方法,同时避免加载依赖真实引擎环境的 dump 模块。 @@ -69,6 +70,17 @@ describe('restoreComponentSnapshotDump', () => { mockRestoreComponentSnapshotProperties.mockReset(); }); + it('filters deleted references at the actual component restore boundary without changing other properties', async () => { + const scene = {}, component = { node: { scene }, onRestore: jest.fn() }; + const dump = { value: { enabled: { value: true }, texture: { type: 'cc.Texture2D', value: { uuid: 'deleted@6c48a' } } } }; + deletedLightmapAssets.begin(scene, ['deleted'])(['deleted']); + await restoreComponentSnapshotDump(component as any, dump); + expect(mockRestoreComponentSnapshotProperties).toHaveBeenCalledWith(component, { + value: { enabled: { value: true }, texture: { type: 'cc.Texture2D', value: { uuid: '' } } }, + }); + expect(dump.value.texture.value.uuid).toBe('deleted@6c48a'); + }); + it.each(['cc.LightProbeGroup', 'CustomProbeGroup'])('rebinds restored %s probe arrays without rebuilding global data', async type => { const old = [1, 2, 3, 4, 5]; const restored = [1, 2, 3, 4]; diff --git a/src/core/scene/test/undo-node-structure-serialization.test.ts b/src/core/scene/test/undo-node-structure-serialization.test.ts index 3ef0d0913..78e6179b3 100644 --- a/src/core/scene/test/undo-node-structure-serialization.test.ts +++ b/src/core/scene/test/undo-node-structure-serialization.test.ts @@ -68,6 +68,7 @@ jest.mock('../scene-process/service/undo/commands/command-utils-shared', () => ( import { editorExtrasTag } from 'cc'; import { captureNodeStructureSnapshot } from '../scene-process/service/undo/commands/node-structure-command-utils'; +import { deletedLightmapAssets } from '../scene-process/service/baking/lightfx/deleted-lightmap-assets'; describe('captureNodeStructureSnapshot serialization', () => { let mockEditorSerialize: jest.Mock; @@ -277,8 +278,10 @@ describe('restoreNodeStructureSnapshot asset map registration', () => { expect(registered[0]._prefab.asset._uuid).toBe('prefab-asset-uuid'); }); - it('does not register in assetToNodesMap when prefabAssetUuid is absent', async () => { + it('filters deleted texture references before loading a historical node without a prefab asset', async () => { const parentNode = new MockNode('parent', 'Parent') as any; + parentNode.scene = {}; + deletedLightmapAssets.begin(parentNode.scene, ['deleted'])(['deleted']); parentNode.addChild = jest.fn((child: any) => { child.parent = parentNode; parentNode.children.push(child); @@ -310,7 +313,7 @@ describe('restoreNodeStructureSnapshot asset map registration', () => { parentUuid: 'parent', parentPath: '/Parent', siblingIndex: 0, - serializedJson: JSON.stringify({ __type__: 'cc.Node' }), + serializedJson: JSON.stringify({ __type__: 'cc.Node', customTexture: { __uuid__: 'deleted@6c48a' }, position: { x: 10 } }), uuidTree: { uuid: 'child-node', componentUuids: [], children: [] }, }; const meta = { id: 'test:id', label: 'test', type: 'test', scope: {}, timestamp: 1 }; @@ -318,6 +321,7 @@ describe('restoreNodeStructureSnapshot asset map registration', () => { await restoreNodeStructureSnapshot(snapshot, meta); expect(mockAssetToNodesMap.size).toBe(0); + expect(mockLoadWithJson.mock.calls[0][0]).toEqual({ __type__: 'cc.Node', customTexture: null, position: { x: 10 } }); }); it('still restores node successfully when prefab asset loading fails', async () => { From 4aad1179a36b922a2125baa373bc0475694fa1c2 Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 18:12:47 +0800 Subject: [PATCH 44/64] =?UTF-8?q?feat/=20=E8=A1=A5=E9=BD=90=E5=85=89?= =?UTF-8?q?=E7=85=A7=E8=B4=B4=E5=9B=BE=E5=8E=9F=E7=94=9F=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E4=B8=8E=E7=83=98=E7=84=99=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scene/main-process/lightfx-bake-host.ts | 54 +++++++++++++++++++ src/core/scene/test/lightfx-bake-host.test.ts | 21 ++++++++ 2 files changed, 75 insertions(+) diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index 3e7911652..303d4a519 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'crypto'; +import { open } from 'fs/promises'; import { appendFile, copy, @@ -115,6 +116,42 @@ export class LightFXBakeHost implements ILightFXBakeHostService { return text.slice(0, 2048); } + private appendLightmapLog(operation: LightFXHostOperation, message: unknown): void { + const logs = this.diagnostics.get(operation.id)!.value.logs; + const text = this.diagnosticText(operation, message).trim(); + if (!text || logs.at(-1) === text) return; + logs.push(text); + if (logs.length > 128) { + logs.splice(0, logs.length - 127); + logs.unshift('[Earlier baking log entries omitted.]'); + } + } + + /** Read native statistics before the temporary workspace is removed; logging cannot fail a bake. */ + private async readNativeLightmapLog(operation: LightFXHostOperation): Promise { + if (operation.target !== 'lightmap') return; + try { + const file = await open(join(operation.workspace, 'lfx.log'), 'r'); + try { + const buffer = Buffer.alloc(256 * 1024 + 1); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + const text = buffer.subarray(0, Math.min(bytesRead, buffer.length - 1)).toString('utf8'); + const seen = new Set(this.diagnostics.get(operation.id)!.value.logs); + const lines = text.split(/\r?\n/); + if (bytesRead === buffer.length) lines.pop(); + for (const line of lines) { + const clean = this.diagnosticText(operation, line).trim(); + if (clean && !seen.has(clean)) { this.appendLightmapLog(operation, clean); seen.add(clean); } + } + if (bytesRead === buffer.length) this.appendLightmapLog(operation, '[Native log exceeds the preview limit.]'); + } finally { await file.close(); } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + this.appendLightmapLog(operation, '[Unable to read the native baking log.]'); + } + } + } + public async reserveSceneOperation(options: IReserveLightFXSceneOperationOptions): Promise { if (!options || !['light-probe', 'lightmap'].includes(options.target) || !['bake', 'clear'].includes(options.action)) { throw new Error('Invalid LightFX scene operation.'); @@ -300,6 +337,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { } operation.state = 'running'; this.diagnostics.get(operation.id)!.value.stage = 'running'; + if (operation.target === 'lightmap') this.appendLightmapLog(operation, 'Baking started'); try { await operation.inputWritePromise; @@ -314,6 +352,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { onLog: message => { if (this.operation !== operation || operation.terminalState) { return; } console.log(`[LightFX] ${message}`); + if (operation.target === 'lightmap') { this.appendLightmapLog(operation, message); return; } const logs = this.diagnostics.get(operation.id)!.value.logs; logs.push(this.diagnosticText(operation, message)); if (logs.length > 128) { logs.shift(); } @@ -322,19 +361,34 @@ export class LightFXBakeHost implements ILightFXBakeHostService { if (this.operation !== operation || operation.terminalState) { return; } const diagnostic = this.diagnostics.get(operation.id)!.value; diagnostic.progress = this.diagnosticText(operation, progress); + if (operation.target === 'lightmap') this.appendLightmapLog(operation, progress); const rate = parseLightFXProgressRate(progress); if (rate === undefined) { delete diagnostic.rate; } else { diagnostic.rate = rate; } }, }); this.throwIfTerminated(operation); + await this.readNativeLightmapLog(operation); const result = decodeLightFXOutput(await readFile(join(operation.outputDir, 'lfx.out'))); + if (operation.target === 'lightmap') { + for (const item of result.meshes) { + if (!this.diagnostics.get(operation.id)!.value.logs.some(line => line.startsWith(`Mesh ${item.id}:`))) { + this.appendLightmapLog(operation, `Mesh ${item.id}: Index(${item.index}) Offset(${item.offset.join(', ')}) Scale(${item.scale.join(', ')})`); + } + } + for (const item of result.terrains) { + this.appendLightmapLog(operation, `Terrain ${item.id} Block ${item.blockId}: Index(${item.index}) Offset(${item.offset.join(', ')}) Scale(${item.scale.join(', ')})`); + } + } const textureUrls = operation.target === 'lightmap' ? await this.stageLightmapAssets(operation) : []; this.throwIfTerminated(operation); operation.state = 'awaiting-commit'; this.diagnostics.get(operation.id)!.value.stage = 'awaiting-commit'; + if (operation.target === 'lightmap' && !this.diagnostics.get(operation.id)!.value.logs.includes('End of the baking.')) { + this.appendLightmapLog(operation, 'End of the baking.'); + } return { result, textureUrls }; } catch (error) { const terminalError = operation.terminalState === 'cancelled' || operation.terminalState === 'expired' diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts index be793403b..0c41be091 100644 --- a/src/core/scene/test/lightfx-bake-host.test.ts +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -138,6 +138,27 @@ describe('LightFXBakeHost', () => { await host.releaseSceneOperation(token); }); + it('keeps Lightmap progress history and reads native statistics before cleanup', async () => { + jest.spyOn(host as any, 'stageLightmapAssets').mockResolvedValue([]); + mockRunnerRun.mockImplementationOnce(async ({ cwd, onProgress }: { cwd: string; onProgress: (value: string) => void }) => { + onProgress('Build lighting 25%'); + onProgress('Build lighting 50%'); + onProgress('Build lighting 100%'); + await outputFile(join(cwd, 'lfx.log'), 'Build lighting 100%\nBake scene stats: objects 3 lights 1 triangles 224\n'); + await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); + }); + const token = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const { operationId } = await host.begin({ ...token, target: 'lightmap', sceneName: 'Scene', textureSources: [], timeoutMs: 120_000 }); + await host.appendInput({ operationId, chunkBase64: Buffer.from('input').toString('base64') }); + await host.run({ operationId }); + await host.commit({ operationId }); + expect((await host.queryDiagnostics({ ...token, operationId, target: 'lightmap' }))?.logs).toEqual([ + 'Baking started', 'Build lighting 25%', 'Build lighting 50%', 'Build lighting 100%', + 'Bake scene stats: objects 3 lights 1 triangles 224', 'End of the baking.', + ]); + await host.releaseSceneOperation(token); + }); + it('reserves before export, rejects missing/wrong ownership and keeps the lease past native commit', async () => { const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); const opts = { target: 'light-probe' as const, sceneName: 'LightProbe', textureSources: [], timeoutMs: 120_000 }; From 481a600fd2209540b1ad287a39f505fa38ff1c3c Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 18:14:36 +0800 Subject: [PATCH 45/64] =?UTF-8?q?docs/=20=E5=90=8C=E6=AD=A5=E5=85=89?= =?UTF-8?q?=E7=85=A7=E8=B4=B4=E5=9B=BEClear=E5=8E=86=E5=8F=B2=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E4=B8=8E=E5=8E=9F=E7=94=9F=E6=97=A5=E5=BF=97=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 40a32968d..f93d0ca20 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -298,7 +298,9 @@ Pink 应在场景打开、烘焙完成和清理完成后调用该工具刷新面 `saveScene` 默认为 `true`,`deleteAssets` 默认为 `false`。调用 `deleteAssets:true` 前必须确认 `queryCapabilities().assetCleanupVersion === 1`;服务也会在修改场景前再次校验实际 Host 能力。成功结果中的 `clearedCount` 是解除绑定的 Mesh 和 Terrain block 总数,`deletedAssetCount`、`retainedAssetCount` 和 `failedAssetCount` 分别表示删除、因引用保留和删除失败的贴图数量。 -删除模式先清空绑定,再序列化实时场景检查候选贴图是否仍被其他字段引用;仍存在的根资源或子资源引用会保留。场景保存成功后清空整个 Scene Undo/Redo 历史,防止更早的 Bake 记录通过 Redo 恢复已经删除的 UUID。Host 仅逐项删除 Asset DB 可验证的不可变 LightFX 贴图,不删除父目录或同目录的其他文件;其他资产仍引用、依赖查询失败或删除失败时保留并报告。 +删除模式先清空绑定,再序列化实时场景检查候选贴图是否仍被其他字段引用;仍存在的根资源或子资源引用会保留。按 2026-09-11 最新 Creator 对齐决定,Clear 不清空节点移动等无关历史:保存成功后只取消 Clear 自身录制,并推进场景级结果代次。Undo/Redo 不恢复任何 Clear 前的 Lightmap 结果(包括未被物理删除的更早 Bake A),但保留普通属性和 SH;Clear 后新的 Bake 历史仍可恢复。快照恢复会清零过期 Mesh/Terrain 绑定、UV 和烘焙标志,同一场景内部重建会转交代次以兼容保留历史的软重载。实际删除的 UUID 另有悬空引用保护;明确保留/失败项解除删除保护,删除结果未知时保守保留。Host 当前仍只逐项删除 Asset DB 可验证的不可变 LightFX 贴图,不删除父目录或同目录其他文件;外部引用、依赖查询失败或删除失败均保留并报告。固定产物布局另行推进,不能据此宣称产物已全面对齐。 + +成功 Bake 会替换完整场景结果:先清空旧绑定再应用本次输出,本次未参与的禁用/排除对象不继续展示旧结果;这些对象也纳入正常重烘焙 Undo 和应用失败恢复范围。 ### 取消烘焙 @@ -390,12 +392,14 @@ LightFX 当前可能输出 Creator 历史协议版本。解析器只接受已知 - 已有另一个 LightFX 任务运行。 - 当前可见场景尚未加载完成,或同时存在多个可见的场景渲染器。 -Bake 和非删除模式 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与结果修改的 MeshRenderer/Terrain 组件(同一 Terrain 多 block 去重)及 Scene 标记,不能只录制不递归的 Scene 根节点;旧引擎缺类型的空纹理引用也会保留在快照中。成功自动保存后以该记录作为保存点;saveScene:false 不隐式保存场景。`deleteAssets:true` 是例外:保存成功后清空整个 Scene Undo/Redo 历史,避免当前或更早的 Bake 记录恢复已删除资源。失败必须区分原生提交前、结果应用中和结果录制后保存阶段,不能对所有错误统一恢复绑定或删除资产,详见“提交与保存失败”。 +Bake 和非删除模式 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与结果修改的 MeshRenderer/Terrain 组件(同一 Terrain 多 block 去重)及 Scene 标记,不能只录制不递归的 Scene 根节点;旧引擎缺类型的空纹理引用也会保留在快照中。成功自动保存后以该记录作为保存点;saveScene:false 不隐式保存场景。`deleteAssets:true` 是例外:保存成功后只取消本次 Clear 录制,不清空整个 Scene Undo/Redo 历史。恢复快照时使 Clear 前的所有烘焙绑定、UV 和标志失效,而节点移动、其他组件参数及探针系数仍按原历史恢复;即使旧纹理因其他引用保留,也不通过本场景旧快照恢复其烘焙效果。Clear 后新生成的烘焙记录仍可撤销。没有删除候选或全部资产保留时同样推进结果代次而保留普通历史。失败必须区分原生提交前、结果应用中和结果录制后保存阶段,不能对所有错误统一恢复绑定或删除资产,详见“提交与保存失败”。 绑定历史与资产版本分别保留:新版本输出不会覆盖旧 PNG,普通 Bake/Clear 的 Undo 通过旧 UUID 恢复旧纹理/UV/场景标记。此前旧版本 CLI 已覆盖丢失的像素无法靠此修复找回。`deleteAssets:true` 只处理 Clear 前实际绑定、且可验证为不可变 LightFX 版本产物的根贴图 UUID;不会删除整个目录。当前实时场景或其他磁盘资产仍引用的贴图会保留,删除操作不可撤销。 ## 验证范围 +2026-09-11 产品对齐补充:Lightmap 日志保留真实 Log/Progress 顺序,原生结束后在临时目录清理前读取 `lfx.log`,补充真实 Mesh/Terrain 输出索引与 UV。日志最多 128 条、每条 2048 字符,原生日志文件读取上限 256 KiB,超限明确提示,缺失日志不伪造成几何统计,也不让烘焙失败。探针日志行为保持原状。当前固定产物布局对齐尚未实施,日志与历史修复不代表资源删除实机问题已通过。 + 当前实现已经验证: - Light Probe Bake/Clear,包含 SH 数据保存和重新加载。 From f58a3e26e9f43302914e26810c2ed00215d3cf81 Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 19:56:56 +0800 Subject: [PATCH 46/64] =?UTF-8?q?fix/=20=E4=BF=AE=E5=A4=8D=E6=9C=AA?= =?UTF-8?q?=E5=90=AF=E7=94=A8=E5=9C=B0=E5=BD=A2=E7=9A=84=E7=83=98=E7=84=99?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E6=B8=85=E7=90=86=E4=B8=8E=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scene-process/service/lightmap-bake.ts | 24 +++++++++++-- .../test/lightmap-result-recording.test.ts | 36 ++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index 03f03e40e..7631f5f04 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -383,7 +383,7 @@ export class LightmapBakeService extends BaseService impleme private clearBindings(bindings: LightmapBinding[]): void { for (const binding of bindings) { if (binding.blockId === undefined) binding.target._updateLightmap(null, 0, 0, 0, 0); - else binding.target._updateLightmap(binding.blockId, null, 0, 0, 0, 0); + else this.updateTerrainBinding(binding, null, 0, 0, 0, 0); } } @@ -391,10 +391,30 @@ export class LightmapBakeService extends BaseService impleme for (const binding of bindings) { const { x, y, z, w } = binding.uv; if (binding.blockId === undefined) binding.target._updateLightmap(binding.texture, x, y, z, w); - else binding.target._updateLightmap(binding.blockId, binding.texture, x, y, z, w); + else this.updateTerrainBinding(binding, binding.texture, x, y, z, w); } } + private updateTerrainBinding(binding: LightmapBinding, texture: Texture2D | null, x: number, y: number, z: number, w: number): void { + const terrain = binding.target; + const blockId = binding.blockId!; + if (terrain.getBlocks()[blockId]) { + terrain._updateLightmap(blockId, texture, x, y, z, w); + return; + } + // A terrain reopened under an inactive parent has serialized results but no + // runtime blocks. The engine setter assumes a block exists and throws after + // mutating the entry. Preserve the data without enabling or rebuilding nodes; + // TerrainBlock.build will consume it when the terrain is later enabled. + const info = terrain._lightmapInfos[blockId]; + if (!info) throw new Error(`Missing Terrain lightmap entry: ${blockId}`); + info.texture = texture; + info.UOff = x; + info.VOff = y; + info.UScale = z; + info.VScale = w; + } + private async waitForAsset(url: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; do { diff --git a/src/core/scene/test/lightmap-result-recording.test.ts b/src/core/scene/test/lightmap-result-recording.test.ts index a78ec526b..33fe19ca9 100644 --- a/src/core/scene/test/lightmap-result-recording.test.ts +++ b/src/core/scene/test/lightmap-result-recording.test.ts @@ -38,7 +38,7 @@ function fixture() { const terrain = { uuid: 'terrain', lightMapSize: 64, _lightmapInfos: [ { texture: oldTexture, UOff: 1, VOff: 2, UScale: 3, VScale: 4 }, { texture: oldTexture, UOff: 5, VOff: 6, UScale: 7, VScale: 8 }, - ], _resetLightmap: jest.fn(), _updateLightmap: jest.fn() }; + ], getBlocks: () => [{}, {}], _resetLightmap: jest.fn(), _updateLightmap: jest.fn() }; const scene = { uuid: 'scene', name: 'test', globals: { bakedWithHighpLightmap: false, bakedWithStationaryMainLight: false }, children: [], getComponents: (type: unknown) => type === mockMeshRenderer ? [model] : type === mockTerrain ? [terrain] : [], }; @@ -57,6 +57,7 @@ function fixture() { describe('Lightmap result recording targets', () => { beforeEach(() => { jest.clearAllMocks(); + mockBake.mockReset(); mockQueryCapabilities.mockResolvedValue({ lightmapAssetCleanupVersion: 1 }); mockQuerySceneSerializedData.mockResolvedValue('[]'); mockRemoveLightmapAssets.mockResolvedValue({ deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }); @@ -169,6 +170,39 @@ describe('Lightmap result recording targets', () => { expect(f.terrain._updateLightmap.mock.calls).toEqual([[0, null, 0, 0, 0, 0], [1, null, 0, 0, 0, 0]]); expect(f.model._updateLightmap).toHaveBeenLastCalledWith(f.texture, 0, 0, 1, 1); }); + it.each(['bake', 'clear', 'delete'] as const)('clears serialized bindings of a reopened inactive Terrain during %s without requiring runtime blocks', async operation => { + const f = fixture(); + f.terrain.getBlocks = () => []; + // Match the engine precondition: calling this setter without a block fails. + f.terrain._updateLightmap.mockImplementation(() => { throw new Error('unbuilt terrain block'); }); + mockBake.mockResolvedValueOnce({ models: [f.model], terrains: [], operationId: 'operation', stationaryMainLight: false, + textureUrls: [], result: { meshes: [{ id: 0, index: 0, offset: [0, 0], scale: [1, 1] }], terrains: [] } }); + if (operation === 'bake') await f.service.bake({ saveScene: false }); + else await f.service.clearBake({ deleteAssets: operation === 'delete' }); + expect(f.terrain._lightmapInfos).toEqual([ + { texture: null, UOff: 0, VOff: 0, UScale: 0, VScale: 0 }, + { texture: null, UOff: 0, VOff: 0, UScale: 0, VScale: 0 }, + ]); + expect(f.terrain._updateLightmap).not.toHaveBeenCalled(); + }); + it.each(['bake', 'clear'] as const)('restores every inactive Terrain block on %s failure without masking the original error', async operation => { + const f = fixture(); + f.terrain.getBlocks = () => []; + const previous = f.terrain._lightmapInfos.map(info => ({ ...info })); + f.terrain._updateLightmap.mockImplementation(() => { throw new Error('unbuilt terrain block'); }); + if (operation === 'bake') { + mockBake.mockResolvedValueOnce({ models: [f.model], terrains: [], operationId: 'operation', stationaryMainLight: false, + textureUrls: [], result: { meshes: [{ id: 0, index: 0, offset: [0, 0], scale: [1, 1] }], terrains: [] } }); + f.model._updateLightmap.mockImplementationOnce(() => {}).mockImplementationOnce(() => { throw new Error('apply failed'); }); + await expect(f.service.bake({ saveScene: false })).rejects.toThrow('apply failed'); + } else { + mockQuerySceneSerializedData.mockRejectedValueOnce(new Error('serialization failed')); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('serialization failed'); + } + expect(f.terrain._lightmapInfos).toEqual(previous); + expect(f.terrain._updateLightmap).not.toHaveBeenCalled(); + expect(mockUndo.cancelRecording).toHaveBeenCalledWith('recording'); + }); it('establishes a Clear history barrier even when current results were already unbound', async () => { const f = fixture(); const old = deletedLightmapAssets.capture(f.scene, { type: 'cc.ModelBakeSettings', value: { From f1bef49c49b20d2473455c31fda8eaa95e64b592 Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 20:28:46 +0800 Subject: [PATCH 47/64] =?UTF-8?q?fix/=20=E8=A1=A5=E9=BD=90=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E5=8E=86=E5=8F=B2=E7=83=98=E7=84=99=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E7=9A=84=E7=B2=BE=E7=A1=AE=E6=B8=85=E7=90=86=E4=B8=8E=E8=90=BD?= =?UTF-8?q?=E7=9B=98=E6=A0=B8=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/scene/common/lightfx-host.ts | 5 + .../scene/main-process/lightfx-bake-host.ts | 50 ++++++-- .../main-process/lightfx/asset-record.ts | 58 +++++++++ .../service/baking/lightfx/baker.ts | 1 + .../scene-process/service/lightmap-bake.ts | 7 +- .../scene/test/lightfx-asset-record.test.ts | 40 ++++++ .../scene/test/lightfx-asset-versions.test.ts | 118 +++++++++++++++++- .../test/lightmap-result-recording.test.ts | 27 ++++ 8 files changed, 295 insertions(+), 11 deletions(-) create mode 100644 src/core/scene/main-process/lightfx/asset-record.ts create mode 100644 src/core/scene/test/lightfx-asset-record.test.ts diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts index 4a6ca0858..a06818527 100644 --- a/src/core/scene/common/lightfx-host.ts +++ b/src/core/scene/common/lightfx-host.ts @@ -53,6 +53,8 @@ export interface IResolvedLightFXTextureSource { } export interface IBeginLightFXBakeOptions { + /** Stable saved scene identity for exact generated-asset cleanup after reopening. */ + sceneUuid?: string; outputUrl?: string; transactionId?: string; target: LightFXBakeTarget; @@ -128,6 +130,8 @@ export interface IRemoveLightmapAssetsResult { export interface IQueryLightmapTextureInfoOptions { uuids: string[]; + /** Also return this scene's known generated assets, without adding them to the preview list. */ + sceneUuid?: string; } export interface ILightmapTextureInfo { @@ -142,6 +146,7 @@ export interface ILightmapTextureInfo { export interface IQueryLightmapTextureInfoResult { textures: ILightmapTextureInfo[]; missingTextureUuids: string[]; + ownedTextureUuids?: string[]; } /** diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index 303d4a519..5ec0ce4e5 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -38,6 +38,7 @@ import type { } from '../common/lightfx-host'; import { assetManager } from '../../assets'; import { LightmapAssetTransaction } from './lightfx/asset-transaction'; +import { LightmapAssetRecord } from './lightfx/asset-record'; import { decodeLightFXOutput } from './lightfx/output'; import { LightFXProcess } from './lightfx/process'; @@ -61,6 +62,8 @@ interface LightFXHostOperation { controller: AbortController; runner: LightFXProcess; assets: LightmapAssetTransaction | null; + assetRecord?: LightmapAssetRecord; + recordedTextureUuids?: string[]; cleanupPromise: Promise | null; expiryTimer: NodeJS.Timeout | null; terminalState: OperationTerminalState | null; @@ -75,6 +78,13 @@ const MAX_INPUT_CHUNK_BASE64_LENGTH = 1024 * 1024; const MAX_INPUT_BYTES = 1024 * 1024 * 1024; const MAX_TEXTURE_SOURCES = 10_000; +function isImmutableLightmapTexture(url: string | undefined): boolean { + const parts = url?.startsWith('db://assets/') ? url.slice('db://assets/'.length).split('/') : []; + const version = parts.at(-2) ?? ''; + return /^LFX_(?:Mesh|Terrain)_\d{4,}\.png$/.test(parts.at(-1) ?? '') + && version.startsWith('bake-') && Utils.UUID.isUUID(version.slice('bake-'.length)); +} + /** Reads only the native percentage format observed on the dedicated Progress channel. */ export function parseLightFXProgressRate(value: unknown): number | undefined { if (typeof value !== 'string') { return undefined; } @@ -231,7 +241,10 @@ export class LightFXBakeHost implements ILightFXBakeHostService { missingTextureUuids.push(uuid); } } - return { textures, missingTextureUuids }; + const ownedTextureUuids = options.sceneUuid === undefined ? undefined + : (await new LightmapAssetRecord(dirname(this.queryAssetRoot()), options.sceneUuid).read()) + .filter(uuid => !!assetManager.queryAssetInfo(uuid)); + return { textures, missingTextureUuids, ...(ownedTextureUuids ? { ownedTextureUuids } : {}) }; } public async begin(options: IBeginLightFXBakeOptions): Promise { @@ -244,6 +257,8 @@ export class LightFXBakeHost implements ILightFXBakeHostService { const assetRoot = this.queryAssetRoot(); const projectRoot = dirname(assetRoot); + const assetRecord = options.target === 'lightmap' && options.sceneUuid !== undefined + ? new LightmapAssetRecord(projectRoot, options.sceneUuid) : undefined; const operationId = randomUUID(); const workspace = join( projectRoot, @@ -277,6 +292,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { controller: new AbortController(), runner: new LightFXProcess(), assets: null, + assetRecord, cleanupPromise: null, expiryTimer: null, terminalState: null, @@ -288,6 +304,8 @@ export class LightFXBakeHost implements ILightFXBakeHostService { if (this.diagnostics.size > MAX_REMEMBERED_OPERATIONS) { this.diagnostics.delete(this.diagnostics.keys().next().value!); } if (this.sceneOperation) this.sceneOperation.nativeStarted = true; try { + // A corrupt/unreadable record must fail before native work or asset publication. + await assetRecord?.read(); if (options.outputUrl !== undefined) { const path = relative(await realpath(assetRoot), await realpath(parentDir)); if (path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path) || !(await stat(parentDir)).isDirectory()) { @@ -504,14 +522,16 @@ export class LightFXBakeHost implements ILightFXBakeHostService { if (!Utils.UUID.isUUID(uuid)) throw new Error('Invalid Lightmap texture UUID.'); return uuid; }))]; + const record = new LightmapAssetRecord(dirname(this.queryAssetRoot()), sceneUuid); + const infos = new Map(uuids.map(uuid => [uuid, assetManager.queryAssetInfo(uuid)])); + const known = uuids.filter(uuid => isImmutableLightmapTexture(infos.get(uuid)?.url)); + // Include legacy currently-bound candidates before deletion so a retained/failed + // delete can be retried after the saved scene no longer has any Lightmap binding. + if (known.length) await record.add(known); const result: IRemoveLightmapAssetsResult = { deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }; for (const uuid of uuids) { - const info = assetManager.queryAssetInfo(uuid); - const parts = info?.url?.startsWith('db://assets/') ? info.url.slice('db://assets/'.length).split('/') : []; - const filename = parts.at(-1) ?? ''; - const version = parts.at(-2) ?? ''; - if (!info?.url || !/^LFX_(?:Mesh|Terrain)_\d{4,}\.png$/.test(filename) - || !version.startsWith('bake-') || !Utils.UUID.isUUID(version.slice('bake-'.length))) { + const info = infos.get(uuid); + if (!info || !isImmutableLightmapTexture(info.url)) { result.failures.push({ uuid, reason: 'Asset is not an immutable LightFX texture.' }); continue; } @@ -531,11 +551,17 @@ export class LightFXBakeHost implements ILightFXBakeHostService { continue; } await assetManager.removeAsset(uuid); + if (info.file && await pathExists(info.file)) { + throw new Error('Lightmap texture file still exists after asset deletion.'); + } result.deletedTextureUuids.push(uuid); } catch (error) { result.failures.push({ uuid, reason: error instanceof Error ? error.message : String(error) }); } } + if (result.deletedTextureUuids.length > 0) { + await record.forget(result.deletedTextureUuids); + } return result; } finally { owner.removingAssets = false; @@ -686,11 +712,18 @@ export class LightFXBakeHost implements ILightFXBakeHostService { } await assetManager.refreshAsset(operation.targetUrl); + const generatedUuids: string[] = []; for (const file of files) { this.throwIfTerminated(operation); const url = `${operation.targetUrl}/${file}`; const uuid = await this.waitForAsset(operation, url, Math.min(operation.timeoutMs, 60_000)); await this.disableAlphaFix(uuid); + generatedUuids.push(uuid); + } + if (operation.assetRecord) { + await operation.assetRecord.add(generatedUuids); + operation.recordedTextureUuids = generatedUuids; + this.throwIfTerminated(operation); } return files.map((file) => `${operation.targetUrl}/${file}`); } @@ -831,6 +864,9 @@ export class LightFXBakeHost implements ILightFXBakeHostService { // retried when either restoration or the following Asset DB refresh fails. await operation.assets.rollback(); await assetManager.refreshAsset(operation.refreshUrl); + if (operation.recordedTextureUuids) { + await operation.assetRecord?.forget(operation.recordedTextureUuids); + } } let cleanupError: unknown; try { diff --git a/src/core/scene/main-process/lightfx/asset-record.ts b/src/core/scene/main-process/lightfx/asset-record.ts new file mode 100644 index 000000000..2cdf034e6 --- /dev/null +++ b/src/core/scene/main-process/lightfx/asset-record.ts @@ -0,0 +1,58 @@ +import { randomUUID } from 'crypto'; +import { ensureDir, readFile, rename, outputFile, remove, stat } from 'fs-extra'; +import { dirname, join } from 'path'; +import Utils from '../../../base/utils'; + +/** Exact generated-asset membership, not an asset/pixel backup or project-wide collector. */ +export class LightmapAssetRecord { + private readonly file: string; + + constructor(projectRoot: string, sceneUuid: string) { + const uuid = Utils.UUID.decompressUUID(sceneUuid).split('@', 1)[0]; + if (!Utils.UUID.isUUID(uuid)) throw new Error('Invalid Lightmap scene UUID.'); + this.file = join(projectRoot, 'settings', 'lightfx-assets', `${uuid}.json`); + } + + async read(): Promise { + let text: string; + try { + if ((await stat(this.file)).size > 512 * 1024) throw new Error('Lightmap generated-asset record is too large.'); + text = await readFile(this.file, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + const record = JSON.parse(text) as { version?: number; textures?: unknown }; + if (record?.version !== 1 || !Array.isArray(record.textures) || record.textures.length > 10_000 + || record.textures.some(uuid => typeof uuid !== 'string' || !Utils.UUID.isUUID(uuid))) { + throw new Error('Invalid Lightmap generated-asset record.'); + } + return [...new Set(record.textures as string[])]; + } + + async add(uuids: readonly string[]): Promise { + const roots = uuids.map(uuid => Utils.UUID.decompressUUID(uuid).split('@', 1)[0]); + if (roots.some(uuid => !Utils.UUID.isUUID(uuid))) throw new Error('Invalid generated Lightmap texture UUID.'); + const textures = [...new Set([...(await this.read()), ...roots])]; + if (textures.length > 10_000) throw new Error('Too many recorded Lightmap assets; clear unused bake results first.'); + await this.write(textures); + } + + async forget(uuids: readonly string[]): Promise { + const deleted = new Set(uuids); + const previous = await this.read(); + const textures = previous.filter(uuid => !deleted.has(uuid)); + if (textures.length !== previous.length) await this.write(textures); + } + + private async write(textures: string[]): Promise { + await ensureDir(dirname(this.file)); + const temporary = `${this.file}.${randomUUID()}.tmp`; + try { + await outputFile(temporary, JSON.stringify({ version: 1, textures })); + await rename(temporary, this.file); + } finally { + await remove(temporary); + } + } +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/baker.ts b/src/core/scene/scene-process/service/baking/lightfx/baker.ts index d17beb41a..270ef20dc 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/baker.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/baker.ts @@ -48,6 +48,7 @@ export class LightFXCoordinator { transactionId, target, sceneName: scene.name, + ...(target === 'lightmap' ? { sceneUuid: scene.uuid } : {}), textureSources: exported.textureSources, timeoutMs, ...(outputUrl !== undefined ? { outputUrl } : {}), diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index 7631f5f04..1832be953 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -180,8 +180,13 @@ export class LightmapBakeService extends BaseService impleme throw new Error('The LightFX host does not support exact Lightmap asset cleanup.'); } + // Query before recording/clearing so a damaged ownership record cannot partially Clear. + // Older hosts ignore sceneUuid and omit the optional list, retaining exact-bound cleanup. + const owned = options.deleteAssets === true + ? (await lightFXBakeHost.queryLightmapTextureInfo({ uuids: [], sceneUuid: scene.uuid })).ownedTextureUuids ?? [] + : []; const bindings = this.snapshotSceneBindings(scene); - const textureUuids = [...new Set(bindings.map(binding => binding.texture?.uuid ?? (binding.texture as any)?._uuid) + const textureUuids = [...new Set([...owned, ...bindings.map(binding => binding.texture?.uuid ?? (binding.texture as any)?._uuid)] .filter((uuid): uuid is string => typeof uuid === 'string' && uuid.length > 0) .map(uuid => this.rootAssetUuid(uuid)))]; const previousHighp = (scene.globals as any).bakedWithHighpLightmap; diff --git a/src/core/scene/test/lightfx-asset-record.test.ts b/src/core/scene/test/lightfx-asset-record.test.ts new file mode 100644 index 000000000..aac839af6 --- /dev/null +++ b/src/core/scene/test/lightfx-asset-record.test.ts @@ -0,0 +1,40 @@ +import { mkdtemp, outputFile, readFile, readdir, remove } from 'fs-extra'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { LightmapAssetRecord } from '../main-process/lightfx/asset-record'; + +describe('Exact scene Lightmap asset membership', () => { + let root: string; + const scene = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const other = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + const a = '11111111-1111-4111-8111-111111111111'; + const b = '22222222-2222-4222-8222-222222222222'; + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'lightfx-record-')); }); + afterEach(async () => { await remove(root); }); + + it('persists only deduplicated root UUIDs and keeps different scenes separate across reopen', async () => { + const record = new LightmapAssetRecord(root, scene); + await record.add([a, `${a}@6c48a`]); + await new LightmapAssetRecord(root, scene).add([b]); + await new LightmapAssetRecord(root, other).add([a]); + await record.forget([a]); + expect(await record.read()).toEqual([b]); + expect(await new LightmapAssetRecord(root, other).read()).toEqual([a]); + expect(await readdir(join(root, 'settings', 'lightfx-assets'))).toEqual(expect.arrayContaining([`${scene}.json`, `${other}.json`])); + expect(await readFile(join(root, 'settings', 'lightfx-assets', `${scene}.json`), 'utf8')) + .toBe(JSON.stringify({ version: 1, textures: [b] })); + }); + + it.each(['../outside', '', 'scene/name'])('rejects unsafe scene identity: %s', invalid => { + expect(() => new LightmapAssetRecord(root, invalid)).toThrow('scene UUID'); + }); + + it.each(['{broken', 'null', '{"version":2,"textures":[]}', '{"version":1,"textures":["../outside"]}'])('does not overwrite a damaged record: %s', async content => { + const file = join(root, 'settings', 'lightfx-assets', `${scene}.json`); + await outputFile(file, content); + const record = new LightmapAssetRecord(root, scene); + await expect(record.add([a])).rejects.toThrow(); + await expect(record.forget([a])).rejects.toThrow(); + expect(await readFile(file, 'utf8')).toBe(content); + }); +}); diff --git a/src/core/scene/test/lightfx-asset-versions.test.ts b/src/core/scene/test/lightfx-asset-versions.test.ts index f32535e92..55856c2a8 100644 --- a/src/core/scene/test/lightfx-asset-versions.test.ts +++ b/src/core/scene/test/lightfx-asset-versions.test.ts @@ -1,10 +1,12 @@ -import { ensureDir, mkdtemp, outputFile, pathExists, readFile, remove, symlink } from 'fs-extra'; +import { ensureDir, existsSync, mkdtemp, outputFile, pathExists, readFile, remove, symlink } from 'fs-extra'; +import { randomUUID } from 'crypto'; import { join } from 'path'; import { tmpdir } from 'os'; const mockAssets = { queryPath: jest.fn(), refreshAsset: jest.fn(), queryUUID: jest.fn(), queryAssetMeta: jest.fn(() => ({ userData: { fixAlphaTransparencyArtifacts: false } })), + queryAssetInfo: jest.fn(), queryAssetUsers: jest.fn(), removeAsset: jest.fn(), }; const mockRun = jest.fn(); jest.mock('../../assets', () => ({ assetManager: mockAssets })); @@ -30,17 +32,127 @@ describe('Immutable Lightmap asset versions', () => { }); afterEach(async () => { await host.dispose(); await remove(root); }); - async function bake(bytes: string, outputUrl?: string) { + async function bake(bytes: string, outputUrl?: string, sceneUuid?: string) { mockRun.mockImplementationOnce(async ({ cwd }: { cwd: string }) => { await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); await outputFile(join(cwd, 'output', 'LFX_Mesh_0000.png'), bytes); }); - const token = await host.begin({ ...opts, outputUrl }); + const token = await host.begin({ ...opts, outputUrl, sceneUuid }); await host.appendInput({ ...token, chunkBase64: Buffer.from('input').toString('base64') }); const output = await host.run(token); return { token, url: output.textureUrls[0], path: assetPath(output.textureUrls[0]) }; } + function realAssetFiles() { + const identities = new Map(); + mockAssets.queryUUID.mockImplementation((url: string) => { + const existing = [...identities.values()].find(info => info.url === url); + if (existing) return existing.uuid; + const uuid = randomUUID(); + identities.set(uuid, { uuid, url, file: assetPath(url) }); + return uuid; + }); + mockAssets.queryAssetInfo.mockImplementation((uuid: string) => { + const info = identities.get(uuid); + return info && existsSync(info.file) ? info : null; + }); + mockAssets.queryAssetUsers.mockReset().mockResolvedValue([]); + mockAssets.removeAsset.mockReset().mockImplementation(async (uuid: string) => { + const info = identities.get(uuid)!; + await remove(info.file); + await remove(`${info.file}.meta`); + identities.delete(uuid); + }); + return identities; + } + + it('remembers unbound products across Host restart and deletes actual files in custom directories', async () => { + const identities = realAssetFiles(); + const sceneUuid = randomUUID(); + await ensureDir(assetRoot); + const a = await bake('pixels A', 'db://assets', sceneUuid); + await host.commit(a.token); + const b = await bake('pixels B', undefined, sceneUuid); + await host.commit(b.token); + await host.dispose(); + host = new LightFXBakeHost(); + const membership = await host.queryLightmapTextureInfo({ uuids: [], sceneUuid }); + expect(membership).toEqual({ textures: [], missingTextureUuids: [], ownedTextureUuids: [...identities.keys()] }); + const cleared = await host.removeLightmapAssets({ sceneUuid, textureUuids: membership.ownedTextureUuids! }); + expect([cleared.deletedTextureUuids.length, cleared.failures, await pathExists(a.path), await pathExists(b.path)]) + .toEqual([2, [], false, false]); + expect((await host.queryLightmapTextureInfo({ uuids: [], sceneUuid })).ownedTextureUuids).toEqual([]); + }); + + it('retains externally used products for retry and isolates identical scene names by UUID', async () => { + const identities = realAssetFiles(); + const sceneUuid = randomUUID(); + const otherScene = randomUUID(); + const a = await bake('pixels A', undefined, sceneUuid); + await host.commit(a.token); + const b = await bake('pixels B', undefined, otherScene); + await host.commit(b.token); + const [aUuid, bUuid] = [...identities.keys()]; + mockAssets.queryAssetUsers.mockResolvedValueOnce([otherScene]); + expect((await host.removeLightmapAssets({ sceneUuid, textureUuids: [aUuid] })).retainedTextureUuids).toEqual([aUuid]); + expect((await host.queryLightmapTextureInfo({ uuids: [], sceneUuid })).ownedTextureUuids).toEqual([aUuid]); + await host.removeLightmapAssets({ sceneUuid, textureUuids: [aUuid] }); + expect([await pathExists(a.path), await pathExists(b.path)]).toEqual([false, true]); + expect((await host.queryLightmapTextureInfo({ uuids: [], sceneUuid: otherScene })).ownedTextureUuids).toEqual([bUuid]); + }); + + it('does not count an asset API success when its PNG still exists', async () => { + const identities = realAssetFiles(); + const sceneUuid = randomUUID(); + const a = await bake('pixels A', undefined, sceneUuid); + await host.commit(a.token); + mockAssets.removeAsset.mockResolvedValueOnce(undefined); + const result = await host.removeLightmapAssets({ sceneUuid, textureUuids: [...identities.keys()] }); + expect([result.deletedTextureUuids, result.failures[0]?.reason, await pathExists(a.path)]) + .toEqual([[], 'Lightmap texture file still exists after asset deletion.', true]); + }); + + it('forgets rolled-back imports without losing earlier product membership', async () => { + const identities = realAssetFiles(); + const sceneUuid = randomUUID(); + const a = await bake('pixels A', undefined, sceneUuid); + await host.commit(a.token); + const aUuid = [...identities.keys()][0]; + const b = await bake('pixels B', undefined, sceneUuid); + await host.rollback(b.token); + expect((await host.queryLightmapTextureInfo({ uuids: [], sceneUuid })).ownedTextureUuids).toEqual([aUuid]); + expect([await pathExists(a.path), await pathExists(b.path)]).toEqual([true, false]); + }); + + it('keeps a failed legacy bound-asset delete in the record for later retry', async () => { + const identities = realAssetFiles(); + const sceneUuid = randomUUID(); + // Legacy begin has no scene identity and therefore no generated-asset record yet. + const a = await bake('legacy pixels'); + await host.commit(a.token); + const uuid = [...identities.keys()][0]; + mockAssets.removeAsset.mockRejectedValueOnce(new Error('busy')); + const result = await host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid] }); + expect(result.failures).toEqual([{ uuid, reason: 'busy' }]); + const retry = (await new LightFXBakeHost().queryLightmapTextureInfo({ uuids: [], sceneUuid })).ownedTextureUuids!; + expect(retry).toEqual([uuid]); + await host.removeLightmapAssets({ sceneUuid, textureUuids: retry }); + expect(await pathExists(a.path)).toBe(false); + }); + + it('rejects a damaged record before native execution or asset deletion', async () => { + const identities = realAssetFiles(); + const sceneUuid = randomUUID(); + const a = await bake('keep pixels'); + await host.commit(a.token); + await outputFile(join(root, 'settings', 'lightfx-assets', `${sceneUuid}.json`), '{broken'); + await expect(host.begin({ ...opts, sceneUuid })).rejects.toThrow(); + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [...identities.keys()] })).rejects.toThrow(); + expect(mockRun).toHaveBeenCalledTimes(1); + expect(await readFile(a.path, 'utf8')).toBe('keep pixels'); + expect(mockAssets.removeAsset).not.toHaveBeenCalled(); + }); + it('publishes distinct assets across same-name bakes without touching legacy files or earlier versions', async () => { const legacy = join(assetRoot, opts.sceneName, 'lightmap', 'LFX_Mesh_0000.png'); await outputFile(legacy, 'legacy pixels'); diff --git a/src/core/scene/test/lightmap-result-recording.test.ts b/src/core/scene/test/lightmap-result-recording.test.ts index 33fe19ca9..074430471 100644 --- a/src/core/scene/test/lightmap-result-recording.test.ts +++ b/src/core/scene/test/lightmap-result-recording.test.ts @@ -16,6 +16,7 @@ const mockUndo = { }; const mockSave = jest.fn(async () => undefined); const mockQuerySceneSerializedData = jest.fn(async () => '[]'); +const mockQueryTextureInfo = jest.fn(async (): Promise<{ textures: []; missingTextureUuids: []; ownedTextureUuids?: string[] }> => ({ textures: [], missingTextureUuids: [] })); jest.mock('cc', () => ({ director: { getScene: mockGetScene }, MeshRenderer: mockMeshRenderer, Terrain: mockTerrain })); jest.mock('../scene-process/service/core', () => ({ BaseService: class { broadcast() {} }, register: () => () => undefined, @@ -23,6 +24,7 @@ jest.mock('../scene-process/service/core', () => ({ })); jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { bake: mockBake, commit: mockCommit, rollback: mockRollback, removeLightmapAssets: mockRemoveLightmapAssets } })); jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { + queryLightmapTextureInfo: mockQueryTextureInfo, queryCapabilities: mockQueryCapabilities, reserveSceneOperation: async () => ({ transactionId: 'owner' }), releaseSceneOperation: async () => undefined, } })); @@ -60,6 +62,7 @@ describe('Lightmap result recording targets', () => { mockBake.mockReset(); mockQueryCapabilities.mockResolvedValue({ lightmapAssetCleanupVersion: 1 }); mockQuerySceneSerializedData.mockResolvedValue('[]'); + mockQueryTextureInfo.mockReset().mockResolvedValue({ textures: [], missingTextureUuids: [] }); mockRemoveLightmapAssets.mockResolvedValue({ deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }); }); it.each([false, true])('records Mesh and Terrain components before scene flags for Bake (save=%s)', async saveScene => { @@ -153,6 +156,30 @@ describe('Lightmap result recording targets', () => { .toBe(outcome === 'deleted' || outcome === 'unknown' ? '' : snapshot.value.uuid); expect(mockUndo.clearHistory).not.toHaveBeenCalled(); }); + it('clears known older products even after all current bindings were removed', async () => { + const f = fixture(); + f.model.bakeSettings.texture = null as any; + f.terrain._lightmapInfos = []; + mockQueryTextureInfo.mockResolvedValueOnce({ textures: [], missingTextureUuids: [], ownedTextureUuids: ['older-A', 'older-B'] }); + await f.service.clearBake({ deleteAssets: true }); + expect(mockRemoveLightmapAssets).toHaveBeenCalledWith('scene', ['older-A', 'older-B']); + expect(mockUndo.clearHistory).not.toHaveBeenCalled(); + }); + it('retains older products used by non-baking fields in the live scene', async () => { + const f = fixture(); + mockQueryTextureInfo.mockResolvedValueOnce({ textures: [], missingTextureUuids: [], ownedTextureUuids: ['older-A', 'old-texture'] }); + mockQuerySceneSerializedData.mockResolvedValueOnce(JSON.stringify({ custom: { __uuid__: 'older-A@f9941' } })); + const result = await f.service.clearBake({ deleteAssets: true }); + expect([result.retainedAssetCount, mockRemoveLightmapAssets.mock.calls]).toEqual([1, [['scene', ['old-texture']]]]); + }); + it('fails before modifying the scene if generated-asset membership cannot be read', async () => { + const f = fixture(); + mockQueryTextureInfo.mockRejectedValueOnce(new Error('record unavailable')); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('record unavailable'); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + expect(f.model._updateLightmap).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalled(); + }); it('keeps history when there are no texture candidates', async () => { const f = fixture(); f.model.bakeSettings.texture = null as any; From ad22316e52c8a8222371c0cd3c01cf00d5b539d9 Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 20:48:24 +0800 Subject: [PATCH 48/64] =?UTF-8?q?fix/=20=E6=88=90=E5=8A=9F=E9=87=8D?= =?UTF-8?q?=E7=83=98=E7=84=99=E5=90=8E=E6=B8=85=E7=90=86=E6=97=A7=E8=B4=B4?= =?UTF-8?q?=E5=9B=BE=E5=B9=B6=E6=94=B6=E6=95=9B=E7=BB=93=E6=9E=9C=E6=92=A4?= =?UTF-8?q?=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/scene/common/lightfx-host.ts | 4 ++ .../scene/main-process/lightfx-bake-host.ts | 14 ++-- .../service/baking/lightfx/baker.ts | 5 +- .../baking/lightfx/deleted-lightmap-assets.ts | 28 ++++++++ .../scene-process/service/lightmap-bake.ts | 37 +++++++++++ .../test/deleted-lightmap-assets.test.ts | 49 ++++++++++++++ .../scene/test/lightfx-asset-versions.test.ts | 35 +++++++++- src/core/scene/test/lightfx-bake-host.test.ts | 2 +- .../test/lightfx-result-failures.test.ts | 36 +++++++--- .../test/lightmap-result-recording.test.ts | 66 ++++++++++++++++++- 10 files changed, 255 insertions(+), 21 deletions(-) diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts index a06818527..25486f09f 100644 --- a/src/core/scene/common/lightfx-host.ts +++ b/src/core/scene/common/lightfx-host.ts @@ -20,6 +20,8 @@ export interface ILightFXHostCapabilities { lightmapOutputDirectory?: true; /** Deletes only unreferenced immutable LightFX textures selected by exact UUID. */ lightmapAssetCleanupVersion?: 1; + /** Supports exact cleanup inside the owning Bake transaction after Scene confirms saving. */ + lightmapRebakeCleanupVersion?: 1; /** Version 1 requires the exact native operation, target and scene reservation to cancel. */ cancelOwnershipVersion?: 1; diagnosticsVersion?: 1; @@ -117,6 +119,8 @@ export interface ICancelLightFXOperationOptions extends ILightFXOperationOptions export interface IRemoveLightmapAssetsOptions { transactionId?: string; + /** Default Clear; Bake cleanup requires its still-held scene reservation. */ + action?: 'bake' | 'clear'; /** Saved scene whose stale dependency entry may be ignored after Scene verified no live reference remains. */ sceneUuid: string; textureUuids: string[]; diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index 5ec0ce4e5..1b29d56de 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -102,11 +102,11 @@ export class LightFXBakeHost implements ILightFXBakeHostService { private operation: LightFXHostOperation | null = null; private readonly completedOperations = new Map(); private readonly diagnostics = new Map(); - private sceneOperation: (IReserveLightFXSceneOperationOptions & ILightFXSceneOperationToken & { nativeStarted: boolean; removingAssets: boolean }) | null = null; + private sceneOperation: (IReserveLightFXSceneOperationOptions & ILightFXSceneOperationToken & { nativeStarted: boolean; nativeCommitted: boolean; removingAssets: boolean }) | null = null; private readonly releasedSceneOperations = new Set(); public async queryCapabilities(): Promise { - return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; + return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; } public async queryDiagnostics(options: ICancelLightFXOperationOptions): Promise { @@ -170,7 +170,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { const transactionId = randomUUID(); // No await before reservation. A lost renderer keeps this locked rather than admitting // another writer while its old scene transaction might still resume. - this.sceneOperation = { target: options.target, action: options.action, transactionId, nativeStarted: false, removingAssets: false }; + this.sceneOperation = { target: options.target, action: options.action, transactionId, nativeStarted: false, nativeCommitted: false, removingAssets: false }; return { transactionId }; } @@ -442,6 +442,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { } // This synchronous decision is the linearization point shared with cancellation and expiry. this.decideTerminalState(operation, 'committed'); + if (this.sceneOperation) this.sceneOperation.nativeCommitted = true; try { await this.cleanup(operation, false); } catch (error) { @@ -509,7 +510,12 @@ export class LightFXBakeHost implements ILightFXBakeHostService { } const sceneUuid = Utils.UUID.decompressUUID(options.sceneUuid).split('@', 1)[0]; if (!Utils.UUID.isUUID(sceneUuid)) throw new Error('Invalid Lightmap scene UUID.'); - this.validateSceneOperation(options.transactionId, 'lightmap', 'clear'); + const action = options.action ?? 'clear'; + if (action !== 'clear' && action !== 'bake') throw new Error('Invalid Lightmap cleanup action.'); + if (action === 'bake' && (!options.transactionId || !this.sceneOperation?.nativeCommitted)) { + throw new Error('Lightmap rebake cleanup requires its completed native bake ownership.'); + } + this.validateSceneOperation(options.transactionId, 'lightmap', action); const legacy = options.transactionId === undefined; const token = legacy ? await this.reserveSceneOperation({ target: 'lightmap', action: 'clear' }) : { transactionId: options.transactionId! }; const owner = this.sceneOperation!; diff --git a/src/core/scene/scene-process/service/baking/lightfx/baker.ts b/src/core/scene/scene-process/service/baking/lightfx/baker.ts index 270ef20dc..617774973 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/baker.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/baker.ts @@ -90,8 +90,9 @@ export class LightFXCoordinator { } } - removeLightmapAssets(sceneUuid: string, textureUuids: string[]): Promise { - return lightFXBakeHost.removeLightmapAssets({ sceneUuid, textureUuids, transactionId: lightFXSceneOperation.hostTransactionId }); + removeLightmapAssets(sceneUuid: string, textureUuids: string[], action: 'bake' | 'clear' = 'clear'): Promise { + return lightFXBakeHost.removeLightmapAssets({ sceneUuid, textureUuids, transactionId: lightFXSceneOperation.hostTransactionId, + ...(action === 'bake' ? { action } : {}) }); } async cancel(target: LightFXBakeTarget): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }> { diff --git a/src/core/scene/scene-process/service/baking/lightfx/deleted-lightmap-assets.ts b/src/core/scene/scene-process/service/baking/lightfx/deleted-lightmap-assets.ts index 7ad766101..071502cc1 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/deleted-lightmap-assets.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/deleted-lightmap-assets.ts @@ -3,6 +3,7 @@ export class DeletedLightmapAssets { private readonly scenes = new WeakMap>(); private readonly epochs = new WeakMap(); private readonly snapshots = new WeakMap(); + private readonly replacements = new WeakMap>(); constructor(private readonly normalize: (uuid: string) => string = rootLightmapAssetUuid) {} @@ -18,6 +19,7 @@ export class DeletedLightmapAssets { capture(scene: object | null | undefined, snapshot: T): T { if (scene && snapshot && typeof snapshot === 'object') { this.snapshots.set(snapshot, this.epochs.get(scene)?.value ?? 0); + this.replacements.get(scene)?.add(snapshot); } return snapshot; } @@ -29,6 +31,32 @@ export class DeletedLightmapAssets { this.epochs.set(scene, epoch); } + /** Start after the Bake's before snapshot. Commit only after recording/save succeeds. */ + beginReplacement(scene: object): { commit(): void; cancel(): void } { + if (this.replacements.has(scene)) throw new Error('A Lightmap result replacement is already being recorded.'); + const captured = new Set(); + this.replacements.set(scene, captured); + let active = true; + const cancel = (): void => { + if (!active) return; + active = false; + this.replacements.delete(scene); + captured.clear(); + }; + return { + commit: () => { + if (!active) return; + this.clearResults(scene); + const epoch = this.epochs.get(scene)!.value; + // The completed Bake can Redo its current result; earlier snapshots cannot + // revive replaced results, even when external references kept their pixels. + for (const snapshot of captured) this.snapshots.set(snapshot, epoch); + cancel(); + }, + cancel, + }; + } + /** Protect in-flight deletes too; an unconfirmed response must not revive possibly deleted assets. */ begin(scene: object, uuids: readonly string[]): (deleted: readonly string[]) => void { const blocked = this.scenes.get(scene) ?? new Set(); diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index 1832be953..8d3c8447e 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -45,6 +45,11 @@ export class LightmapBakeService extends BaseService impleme if (!scene) throw new Error('No scene is currently open.'); const sceneUrl = await this.querySceneUrl(); + // Preflight before native publication or scene mutation, not after a successful save. + if ((await lightFXBakeHost.queryCapabilities())?.lightmapRebakeCleanupVersion !== 1) { + throw new Error('The LightFX host does not support safe Lightmap rebake cleanup.'); + } + const owned = (await lightFXBakeHost.queryLightmapTextureInfo({ uuids: [], sceneUuid: scene.uuid })).ownedTextureUuids ?? []; const settings = createDefaultLightFXSettings('lightmap'); Object.assign(settings, { msaa: options.msaa ?? settings.msaa, @@ -78,6 +83,9 @@ export class LightmapBakeService extends BaseService impleme await lightFXCoordinator.commit(output.operationId); nativeCommitted = true; const previousBindings = this.snapshotSceneBindings(scene); + const previousTextureUuids = [...new Set([...owned, ...previousBindings.map(binding => binding.texture?.uuid ?? (binding.texture as any)?._uuid)] + .filter((uuid): uuid is string => typeof uuid === 'string' && uuid.length > 0) + .map(uuid => this.rootAssetUuid(uuid)))]; const affectedBindings = [...previousBindings, ...this.snapshotBindings(output)]; const previousHighp = (scene.globals as any).bakedWithHighpLightmap; const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; @@ -86,6 +94,7 @@ export class LightmapBakeService extends BaseService impleme const targets = [...new Set([...output.models, ...output.terrains, ...previousBindings.map(binding => binding.target)] .map(component => component.uuid)), scene.uuid]; const undo = Service.Undo.beginRecording(targets, { label: 'Bake lightmap' }); + const replacement = deletedLightmapAssets.beginReplacement(scene); try { // A successful bake replaces the complete result, including disabled objects // that were excluded from this export but still have older bindings. @@ -96,6 +105,7 @@ export class LightmapBakeService extends BaseService impleme await Service.Engine.repaintInEditMode(); await finishSavedLightFXRecording(Service.Undo, undo, options.saveScene !== false ? () => Service.Editor.save({}) : undefined); + replacement.commit(); } catch (error) { if (error instanceof LightFXResultRetainedError) throw error; this.restoreBindings(affectedBindings); @@ -103,6 +113,14 @@ export class LightmapBakeService extends BaseService impleme (scene.globals as any).bakedWithStationaryMainLight = previousStationary; Service.Undo.cancelRecording(undo); throw error; + } finally { + replacement.cancel(); + } + + // Never put deletion in the apply rollback scope. The scene may already be on disk. + // Explicitly unsaved bakes retain pixels still needed by the saved scene, not for Undo. + if (options.saveScene !== false) { + await this.cleanupPreviousBake(scene, previousTextureUuids, textures); } this.broadcast('lightfx:bake-end', 'lightmap'); @@ -123,6 +141,25 @@ export class LightmapBakeService extends BaseService impleme } } + private async cleanupPreviousBake(scene: Scene, candidates: string[], textures: Map): Promise { + try { + const current = new Set([...textures.values()].map(texture => this.rootAssetUuid(texture.uuid))); + const previous = candidates.filter(uuid => !current.has(uuid)); + const retained = await this.queryRemainingSceneTextureUuids(previous); + const deletable = previous.filter(uuid => !retained.has(uuid)); + const finishDeletion = deletedLightmapAssets.begin(scene, deletable); + const result = deletable.length > 0 + ? await lightFXCoordinator.removeLightmapAssets(scene.uuid, deletable, 'bake') + : { deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }; + finishDeletion(result.deletedTextureUuids); + if (retained.size || result.retainedTextureUuids.length || result.failures.length) { + throw new Error(`Previous Lightmap cleanup incomplete: ${retained.size + result.retainedTextureUuids.length} referenced assets retained, ${result.failures.length} deletions failed.`); + } + } catch (error) { + throw new Error(`New Lightmap result is saved and retained; previous asset cleanup was not completed. ${this.errorMessage(error)}`); + } + } + async queryBakeInfo(): Promise { const scene = director.getScene() as Scene | null; if (!scene) throw new Error('No scene is currently open.'); diff --git a/src/core/scene/test/deleted-lightmap-assets.test.ts b/src/core/scene/test/deleted-lightmap-assets.test.ts index d05f69399..01aea4468 100644 --- a/src/core/scene/test/deleted-lightmap-assets.test.ts +++ b/src/core/scene/test/deleted-lightmap-assets.test.ts @@ -11,6 +11,55 @@ const terrain = (uuid: string) => ({ type: 'cc.TerrainBlockLightmapInfo', value: } }); describe('Deleted Lightmap snapshot references', () => { + it('invalidates successive rebakes while preserving current Redo, ordinary edits and SH', async () => { + const scene = {}, deleted = new DeletedLightmapAssets(); + let state = { position: 0, mesh: mesh('A'), terrain: [terrain('A')], sh: [1, 2, 3] }; + const manager = new SceneUndoManager({ snapshotAdapter: { + capture: () => new Map([['state', deleted.capture(scene, structuredClone(state))]]), + equals: (a, b) => JSON.stringify([...a]) === JSON.stringify([...b]), + apply: snapshots => { state = deleted.filter(scene, snapshots.get('state'), 'dump'); return { success: true }; }, + } }); + const move = manager.beginRecording(['node']); + state.position = 10; + await manager.endRecording(move); + for (const texture of ['B', 'C']) { + const bake = manager.beginRecording(['mesh']); + const replacement = deleted.beginReplacement(scene); + state.mesh = mesh(texture); state.terrain = [terrain(texture)]; + await manager.endRecording(bake); + manager.markSaved(); + replacement.commit(); + replacement.commit(); // Completion is idempotent. + } + await manager.undo(); + expect([state.mesh.value.texture.value.uuid, state.terrain[0].value.UScale.value, state.position, state.sh]) + .toEqual(['', 0, 10, [1, 2, 3]]); + await manager.undo(); await manager.undo(); + expect(state.position).toBe(0); + await manager.redo(); await manager.redo(); + expect([state.position, state.mesh.value.texture.value.uuid]).toEqual([10, '']); + await manager.redo(); + expect([state.mesh, state.terrain, state.sh, manager.isDirty()]).toEqual([mesh('C'), [terrain('C')], [1, 2, 3], false]); + deleted.clearResults(scene); + await manager.undo(); await manager.redo(); + expect(state.mesh.value.texture.value.uuid).toBe(''); + }); + + it('does not invalidate results on failed recording/save and releases replacement capture state', () => { + const scene = {}, deleted = new DeletedLightmapAssets(); + const before = deleted.capture(scene, mesh('A')); + const failed = deleted.beginReplacement(scene); + expect(() => deleted.beginReplacement(scene)).toThrow('already being recorded'); + const retained = deleted.capture(scene, mesh('B')); + failed.cancel(); failed.commit(); + expect(deleted.filter(scene, before, 'dump')).toBe(before); + expect(deleted.filter(scene, retained, 'dump')).toBe(retained); + const retry = deleted.beginReplacement(scene); + const after = deleted.capture(scene, mesh('C')); + retry.commit(); + expect(deleted.filter(scene, retained, 'dump').value.texture.value.uuid).toBe(''); + expect(deleted.filter(scene, after, 'dump')).toBe(after); + }); it('keeps ordinary edits but invalidates both Bake A and B at Clear, while allowing later Bake C history', async () => { const scene = {}, deleted = new DeletedLightmapAssets(); let state = { position: 0, mesh: mesh('A'), terrain: [terrain('A'), terrain('A')], sh: [1, 2, 3] }; diff --git a/src/core/scene/test/lightfx-asset-versions.test.ts b/src/core/scene/test/lightfx-asset-versions.test.ts index 55856c2a8..ccb54080c 100644 --- a/src/core/scene/test/lightfx-asset-versions.test.ts +++ b/src/core/scene/test/lightfx-asset-versions.test.ts @@ -32,12 +32,12 @@ describe('Immutable Lightmap asset versions', () => { }); afterEach(async () => { await host.dispose(); await remove(root); }); - async function bake(bytes: string, outputUrl?: string, sceneUuid?: string) { + async function bake(bytes: string, outputUrl?: string, sceneUuid?: string, transactionId?: string) { mockRun.mockImplementationOnce(async ({ cwd }: { cwd: string }) => { await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); await outputFile(join(cwd, 'output', 'LFX_Mesh_0000.png'), bytes); }); - const token = await host.begin({ ...opts, outputUrl, sceneUuid }); + const token = await host.begin({ ...opts, outputUrl, sceneUuid, transactionId }); await host.appendInput({ ...token, chunkBase64: Buffer.from('input').toString('base64') }); const output = await host.run(token); return { token, url: output.textureUrls[0], path: assetPath(output.textureUrls[0]) }; @@ -66,6 +66,35 @@ describe('Immutable Lightmap asset versions', () => { return identities; } + it('cleans prior files inside the committed Bake reservation without deleting the new result', async () => { + const identities = realAssetFiles(), sceneUuid = randomUUID(); + const a = await bake('pixels A', undefined, sceneUuid); + await host.commit(a.token); + const oldUuid = [...identities.keys()][0]; + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const cleanup = { ...owner, sceneUuid, textureUuids: [oldUuid], action: 'bake' as const }; + await expect(host.removeLightmapAssets(cleanup)).rejects.toThrow('ownership'); + const b = await bake('pixels B', undefined, sceneUuid, owner.transactionId); + await expect(host.removeLightmapAssets(cleanup)).rejects.toThrow('already in progress'); + await host.commit(b.token); + await expect(host.removeLightmapAssets({ ...cleanup, transactionId: randomUUID() })).rejects.toThrow('ownership'); + await expect(host.removeLightmapAssets(cleanup)).resolves.toEqual({ deletedTextureUuids: [oldUuid], retainedTextureUuids: [], failures: [] }); + expect([await pathExists(a.path), await readFile(b.path, 'utf8'), (await host.queryCapabilities()).busy]) + .toEqual([false, 'pixels B', true]); + await host.releaseSceneOperation(owner); + expect((await host.queryLightmapTextureInfo({ sceneUuid, uuids: [] })).ownedTextureUuids).toEqual([...identities.keys()]); + }); + + it('does not accept rebake cleanup after a rolled-back native operation or without a reservation', async () => { + const sceneUuid = randomUUID(); + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const b = await bake('pixels B', undefined, undefined, owner.transactionId); + await host.rollback(b.token); + await expect(host.removeLightmapAssets({ ...owner, sceneUuid, textureUuids: [], action: 'bake' })).rejects.toThrow('ownership'); + await host.releaseSceneOperation(owner); + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [], action: 'bake' })).rejects.toThrow('ownership'); + }); + it('remembers unbound products across Host restart and deletes actual files in custom directories', async () => { const identities = realAssetFiles(); const sceneUuid = randomUUID(); @@ -216,6 +245,6 @@ describe('Immutable Lightmap asset versions', () => { mockAssets.refreshAsset.mockRejectedValueOnce(new Error('import unavailable')); await expect(bake('pixels B')).rejects.toThrow('import unavailable'); expect(await readFile(a.path, 'utf8')).toBe('pixels A'); - await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }); + await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }); }); }); diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts index 0c41be091..360c364fc 100644 --- a/src/core/scene/test/lightfx-bake-host.test.ts +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -90,7 +90,7 @@ describe('LightFXBakeHost', () => { }); it('queries protocol and occupancy without reserving, releasing or exposing ownership', async () => { - const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }; + const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }; const busy = { ...idle, busy: true }; await expect(host.queryCapabilities()).resolves.toEqual(idle); const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); diff --git a/src/core/scene/test/lightfx-result-failures.test.ts b/src/core/scene/test/lightfx-result-failures.test.ts index c912bdcfe..f5457276f 100644 --- a/src/core/scene/test/lightfx-result-failures.test.ts +++ b/src/core/scene/test/lightfx-result-failures.test.ts @@ -10,18 +10,21 @@ class MockVec3 { } const mockBake = jest.fn(), mockCommit = jest.fn(), mockRollback = jest.fn(); const mockSave = jest.fn(), mockRepaint = jest.fn(); +const mockRemoveLightmapAssets = jest.fn(), mockQuerySceneSerializedData = jest.fn(); const mockUndo = { beginRecording: jest.fn(), endRecording: jest.fn(), cancelRecording: jest.fn(), createCheckpoint: jest.fn() }; jest.mock('cc', () => ({ director: { getScene: mockGetScene }, MeshRenderer: mockMeshRenderer, Terrain: mockTerrain, Vec3: MockVec3, SH: { getBasisCount: () => 9 } })); jest.mock('../scene-process/service/core', () => ({ BaseService: class { broadcast() {} }, register: () => () => undefined, - Service: { Undo: mockUndo, Editor: { save: mockSave }, Engine: { repaintInEditMode: mockRepaint } }, + Service: { Undo: mockUndo, Editor: { save: mockSave, querySceneSerializedData: mockQuerySceneSerializedData }, Engine: { repaintInEditMode: mockRepaint } }, })); jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { - bake: mockBake, commit: mockCommit, rollback: mockRollback, + bake: mockBake, commit: mockCommit, rollback: mockRollback, removeLightmapAssets: mockRemoveLightmapAssets, } })); jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { reserveSceneOperation: async () => ({ transactionId: 'owner' }), releaseSceneOperation: async () => undefined, + queryCapabilities: async () => ({ lightmapRebakeCleanupVersion: 1 }), + queryLightmapTextureInfo: async () => ({ textures: [], missingTextureUuids: [], ownedTextureUuids: [] }), } })); jest.mock('../scene-process/service/baking/lightfx/settings', () => ({ createDefaultLightFXSettings: () => ({}) })); jest.mock('../scene-process/service/preview/asset-reload', () => ({ loadPreviewAsset: jest.fn() })); @@ -30,6 +33,7 @@ jest.mock('../scene-process/rpc', () => ({ Rpc: { getInstance: jest.fn() } })); import { LightmapBakeService } from '../scene-process/service/lightmap-bake'; import { LightProbeBakeService } from '../scene-process/service/light-probe-bake'; import { SceneUndoManager } from '../scene-process/service/undo/scene-undo-manager'; +import { deletedLightmapAssets } from '../scene-process/service/baking/lightfx/deleted-lightmap-assets'; function fixture(target: 'probe' | 'lightmap') { const events: string[] = []; @@ -51,11 +55,23 @@ function fixture(target: 'probe' | 'lightmap') { highp: scene.globals.bakedWithHighpLightmap, stationary: scene.globals.bakedWithStationaryMainLight, giScale: info.giScale, probes: probes.map(p => ({ normal: p.normal.clone(), coefficients: p.coefficients.map(c => c.clone()) })) }); let disk = read(); + const capture = () => ({ state: read(), baked: { + __type__: 'cc.ModelBakeSettings', texture: model.bakeSettings.texture ? { __uuid__: model.bakeSettings.texture.uuid } : null, + uvParam: model.bakeSettings.uvParam.clone(), + }, globals: { __type__: 'cc.SceneGlobals', bakedWithHighpLightmap: scene.globals.bakedWithHighpLightmap, + bakedWithStationaryMainLight: scene.globals.bakedWithStationaryMainLight } }); + mockQuerySceneSerializedData.mockImplementation(async () => JSON.stringify(capture())); + mockRemoveLightmapAssets.mockImplementation(async (_scene: string, uuids: string[]) => { + assets = assets.filter(uuid => !uuids.includes(uuid)); + return { deletedTextureUuids: uuids, retainedTextureUuids: [], failures: [] }; + }); const manager = new SceneUndoManager({ snapshotAdapter: { - capture: () => new Map([['scene', read()]]), + capture: () => new Map([['scene', deletedLightmapAssets.capture(scene, capture())]]), equals: (a, b) => JSON.stringify(a.get('scene')) === JSON.stringify(b.get('scene')), apply: data => { - const state = data.get('scene') as ReturnType; + const filtered = deletedLightmapAssets.filter(scene, data.get('scene') as ReturnType, 'serialized'); + const state = { ...filtered.state, texture: filtered.baked.texture?.__uuid__ ?? null, uv: filtered.baked.uvParam, + highp: filtered.globals.bakedWithHighpLightmap, stationary: filtered.globals.bakedWithStationaryMainLight }; model._updateLightmap(state.texture ? { uuid: state.texture } : null, state.uv.x, state.uv.y, state.uv.z, state.uv.w); scene.globals.bakedWithHighpLightmap = state.highp; scene.globals.bakedWithStationaryMainLight = state.stationary; @@ -92,13 +108,15 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t expect({ events: f.events, disk: f.disk(), dirty: f.manager.isDirty() }).toEqual({ events: ['commit', 'record', 'save'], disk: f.read(), dirty: false }); }); - it.each([false, true])('restores complete results in edit → rebake → clear history (save=%s)', async saveScene => { + it.each([false, true])('keeps ordinary edits and only current Lightmap results in edit → rebake → clear history (save=%s)', async saveScene => { const f = fixture(target); const scene = mockGetScene(); const edit = f.manager.beginRecording(['scene']); scene.globals.lightProbeInfo.giScale = 1.5; await f.manager.endRecording(edit); const edited = f.read(); + const previous = (value: ReturnType) => target === 'lightmap' + ? { ...value, texture: null, uv: { x: 0, y: 0, z: 0, w: 0 }, highp: false, stationary: false } : value; await f.service.bake({ giScale: 2, highp: true, saveScene }); const baked = f.read(); @@ -110,20 +128,20 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t await f.manager.undo(); expect(f.read()).toEqual(baked); await f.manager.undo(); - expect(f.read()).toEqual(edited); + expect(f.read()).toEqual(previous(edited)); await f.manager.undo(); - expect(f.read()).toEqual(f.old); + expect(f.read()).toEqual(previous(f.old)); expect(f.manager.canUndo()).toBe(false); await f.manager.redo(); - expect(f.read()).toEqual(edited); + expect(f.read()).toEqual(previous(edited)); await f.manager.redo(); expect(f.read()).toEqual(baked); await f.manager.redo(); expect(f.read()).toEqual(cleared); expect(f.manager.canRedo()).toBe(false); expect(f.manager.isDirty()).toBe(!saveScene); - expect(f.assets()).toEqual(['old', 'new']); + expect(f.assets()).toEqual(target === 'lightmap' && saveScene ? ['new'] : ['old', 'new']); }); it.each([false, true])('does not mutate scene, disk or history on commit failure (host committed=%s)', async committed => { diff --git a/src/core/scene/test/lightmap-result-recording.test.ts b/src/core/scene/test/lightmap-result-recording.test.ts index 074430471..93ee43060 100644 --- a/src/core/scene/test/lightmap-result-recording.test.ts +++ b/src/core/scene/test/lightmap-result-recording.test.ts @@ -5,7 +5,7 @@ const mockBake = jest.fn(); const mockCommit = jest.fn(); const mockRollback = jest.fn(); const mockRemoveLightmapAssets = jest.fn(); -const mockQueryCapabilities = jest.fn(async (): Promise<{ lightmapAssetCleanupVersion?: 1 }> => ({ lightmapAssetCleanupVersion: 1 })); +const mockQueryCapabilities = jest.fn(async (): Promise<{ lightmapAssetCleanupVersion?: 1; lightmapRebakeCleanupVersion?: 1 }> => ({ lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1 })); const mockUndo = { beginRecording: jest.fn(() => 'recording'), endRecording: jest.fn(async () => undefined), @@ -60,7 +60,7 @@ describe('Lightmap result recording targets', () => { beforeEach(() => { jest.clearAllMocks(); mockBake.mockReset(); - mockQueryCapabilities.mockResolvedValue({ lightmapAssetCleanupVersion: 1 }); + mockQueryCapabilities.mockResolvedValue({ lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1 }); mockQuerySceneSerializedData.mockResolvedValue('[]'); mockQueryTextureInfo.mockReset().mockResolvedValue({ textures: [], missingTextureUuids: [] }); mockRemoveLightmapAssets.mockResolvedValue({ deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }); @@ -89,6 +89,68 @@ describe('Lightmap result recording targets', () => { expect(mockSave).toHaveBeenCalledTimes(saveScene ? 1 : 0); expect(mockUndo.markSaved).not.toHaveBeenCalled(); }); + it('deletes previous and older unbound products only after the new result is saved', async () => { + const f = fixture(); + mockQueryTextureInfo.mockResolvedValueOnce({ textures: [], missingTextureUuids: [], ownedTextureUuids: ['older-A', 'old-texture', 'new-texture'] }); + await f.service.bake(); + expect(mockRemoveLightmapAssets).toHaveBeenCalledWith('scene', ['older-A', 'old-texture'], 'bake'); + expect(mockSave.mock.invocationCallOrder[0]).toBeLessThan(mockRemoveLightmapAssets.mock.invocationCallOrder[0]); + expect(mockUndo.clearHistory).not.toHaveBeenCalled(); + }); + it('keeps disk-dependent old pixels for an explicitly unsaved Bake, without retaining old result history', async () => { + const f = fixture(); + const old = deletedLightmapAssets.capture(f.scene, { type: 'cc.ModelBakeSettings', value: { + texture: { type: 'cc.Texture2D', value: { uuid: 'old-texture' } }, + } }); + await f.service.bake({ saveScene: false }); + expect(mockSave).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + expect(deletedLightmapAssets.filter(f.scene, old, 'dump').value.texture.value.uuid).toBe(''); + }); + it('retains and reports old products referenced by non-Lightmap scene fields', async () => { + const f = fixture(); + mockQuerySceneSerializedData.mockResolvedValueOnce(JSON.stringify({ custom: { __uuid__: 'old-texture@f9941' } })); + await expect(f.service.bake()).rejects.toThrow('New Lightmap result is saved and retained'); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + expect(f.model._updateLightmap).toHaveBeenLastCalledWith(f.texture, 0.1, 0.2, 0.3, 0.4); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(mockRollback).not.toHaveBeenCalled(); + }); + it.each(['referenced', 'failed', 'unknown', 'serialization'])('retains the saved new result on %s cleanup failure', async failure => { + const f = fixture(); + if (failure === 'serialization') mockQuerySceneSerializedData.mockRejectedValueOnce(new Error('serialization failed')); + else if (failure === 'unknown') mockRemoveLightmapAssets.mockRejectedValueOnce(new Error('response lost')); + else mockRemoveLightmapAssets.mockResolvedValueOnce({ deletedTextureUuids: [], + retainedTextureUuids: failure === 'referenced' ? ['old-texture'] : [], + failures: failure === 'failed' ? [{ uuid: 'old-texture', reason: 'permission denied' }] : [], + }); + await expect(f.service.bake()).rejects.toThrow('New Lightmap result is saved and retained'); + expect(mockSave).toHaveBeenCalledTimes(1); + expect(f.model._updateLightmap).toHaveBeenLastCalledWith(f.texture, 0.1, 0.2, 0.3, 0.4); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(mockRollback).not.toHaveBeenCalled(); + }); + it('does not invalidate old history or delete pixels after an unconfirmed Bake save', async () => { + const f = fixture(); + const old = deletedLightmapAssets.capture(f.scene, { type: 'cc.ModelBakeSettings', value: { + texture: { type: 'cc.Texture2D', value: { uuid: 'old-texture' } }, + } }); + mockSave.mockRejectedValueOnce(new Error('save response lost')); + await expect(f.service.bake()).rejects.toThrow('result retained'); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + expect(deletedLightmapAssets.filter(f.scene, old, 'dump')).toBe(old); + // Failure releases the replacement capture scope, permitting a later retry. + await f.service.bake(); + }); + it.each(['capability', 'ownership'])('rejects unavailable %s before native Bake or scene mutation', async failure => { + const f = fixture(); + if (failure === 'capability') mockQueryCapabilities.mockResolvedValueOnce({ lightmapAssetCleanupVersion: 1 }); + else mockQueryTextureInfo.mockRejectedValueOnce(new Error('record unavailable')); + await expect(f.service.bake()).rejects.toThrow(); + expect(mockBake).not.toHaveBeenCalled(); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalled(); + }); it('saves before exact deletion and cancels only the Clear recording', async () => { const f = fixture(); mockRemoveLightmapAssets.mockResolvedValueOnce({ From f58779a71e41531be4cb6ea6c13c4ed48ad0f998 Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 20:49:57 +0800 Subject: [PATCH 49/64] =?UTF-8?q?docs/=20=E5=90=8C=E6=AD=A5=E9=87=8D?= =?UTF-8?q?=E7=83=98=E7=84=99=E6=97=A7=E4=BA=A7=E7=89=A9=E6=B8=85=E7=90=86?= =?UTF-8?q?=E4=B8=8E=E7=BB=93=E6=9E=9C=E5=8E=86=E5=8F=B2=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index f93d0ca20..848ebe828 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -146,9 +146,9 @@ Scene runtime 先通过内部 `reserveSceneOperation` 取得宿主生成的事 该操作清除当前场景全部探针的烘焙结果,通知引擎刷新,并作为一次 Undo 操作记录。成功结果中的 `probeCount` 表示处理的探针数量。 -当前明确保留完整结果撤销修复:Probe Clear 可撤销恢复旧 SH,Lightmap 重烘焙可撤销恢复旧纹理/UV/场景标记。这沿用已有录制能力并修复快照恢复不完整的问题;按用户决定放弃后来“不恢复旧结果”的收敛。该行为与已记录的 Creator 3.8.8 实测存在差异,不宣称完全对齐。 +Probe Clear 继续保留完整旧 SH 撤销。Lightmap 按用户最新决定收敛:成功重烘焙后不恢复此前旧纹理/UV/场景标记,本次有效结果可以 Redo;删除模式 Clear 后本次及更早结果均失效。不要把探针完整撤销同样改掉。 -Probe/Lightmap Bake/Clear 的 `saveScene` 默认是 `true`:完整成功后,当前结果作为 Undo 的已保存基线;Undo 回到旧结果会变脏,Redo 回到已保存结果恢复干净。显式传 `false` 时只修改内存并保留 dirty,调用方需要另行保存。 +Probe/Lightmap Bake/Clear 的 `saveScene` 默认是 `true`:完整成功后,当前结果作为已保存基线;Undo 离开保存点会变脏,Redo 回到已保存的有效结果恢复干净。Lightmap 旧结果须经过失效过滤,不能因历史尚存就恢复已被替换的贴图。显式传 `false` 时只修改内存并保留 dirty,调用方需要另行保存。 ### 提交与保存失败 @@ -300,7 +300,7 @@ Pink 应在场景打开、烘焙完成和清理完成后调用该工具刷新面 删除模式先清空绑定,再序列化实时场景检查候选贴图是否仍被其他字段引用;仍存在的根资源或子资源引用会保留。按 2026-09-11 最新 Creator 对齐决定,Clear 不清空节点移动等无关历史:保存成功后只取消 Clear 自身录制,并推进场景级结果代次。Undo/Redo 不恢复任何 Clear 前的 Lightmap 结果(包括未被物理删除的更早 Bake A),但保留普通属性和 SH;Clear 后新的 Bake 历史仍可恢复。快照恢复会清零过期 Mesh/Terrain 绑定、UV 和烘焙标志,同一场景内部重建会转交代次以兼容保留历史的软重载。实际删除的 UUID 另有悬空引用保护;明确保留/失败项解除删除保护,删除结果未知时保守保留。Host 当前仍只逐项删除 Asset DB 可验证的不可变 LightFX 贴图,不删除父目录或同目录其他文件;外部引用、依赖查询失败或删除失败均保留并报告。固定产物布局另行推进,不能据此宣称产物已全面对齐。 -成功 Bake 会替换完整场景结果:先清空旧绑定再应用本次输出,本次未参与的禁用/排除对象不继续展示旧结果;这些对象也纳入正常重烘焙 Undo 和应用失败恢复范围。 +成功 Bake 会替换完整场景结果:先清空旧绑定再应用本次输出,本次未参与的禁用/排除对象不继续展示旧结果;这些对象纳入结果记录及应用失败恢复范围。只有应用/录制/保存失败时才保留旧结果撤销;完整成功后旧 Lightmap 历史失效,普通属性和探针历史不变。 ### 取消烘焙 @@ -336,7 +336,17 @@ Scene 侧 `LightProbeBake.cancel()`/`LightmapBake.cancel()` 只取消本 rende 取消成功后,取消工具本身返回 `code: 200`;原烘焙请求结束并返回 `code: 500`、`reason: "LightFX bake was cancelled."`。这是被取消任务的预期终态。 -## Lightmap 资产规则 +## 2026-09-11 最新产品决定与实施顺序 + +用户已明确不再为普通重烘焙 Undo 保留旧贴图。成功重烘焙应替换并清理旧产物;Clear 后同样不能恢复旧图/UV/效果,节点移动等普通编辑历史和探针 SH 撤销不变。此决定覆盖本文历史版本关于保留所有成功烘焙版本的描述。 + +当前 `455e8687` 已按场景 UUID 持久记录实际导入的根资产 UUID,Clear 合并当前绑定和已知旧产物,逐项核对引用、删除结果和源文件存在性;不保存历史像素副本,不做项目 GC。 + +本批补齐成功重烘焙的收尾链路:修改场景前校验 Host 的内部 `lightmapRebakeCleanupVersion === 1` 并读取旧候选;新结果应用、录制和保存确认后,使旧 Lightmap 历史失效(保留本次新结果的 Redo 和普通历史),检查实时剩余引用并清理旧候选。Host 只接受仍持有正确 Bake reservation 且原生已 commit 的 `action:bake` 清理。当前新结果、其他字段/场景/材质引用必须保留。删除失败、引用保留或回应不明时返回包含 `New Lightmap result is saved and retained` 的错误,保留已完成的新结果并明确报告,不再恢复旧内存冒充回滚;不能把它解释成 Bake 没有修改场景。 + +`saveScene:false` 不授权删除磁盘已保存场景仍依赖的贴图,本批不隐式保存,也不删除旧产物;成功后的旧结果历史仍失效。保存失败/取消/应用失败不触发旧产物清理。下一次成功保存的 Bake 或删除模式 Clear 可重试已记录产物。固定 `LightFX/output` 与 `tmp/lfx.in`/`output/lfx.out`/`lfx.log` 发布另行接入,须先解决发布与保存之间的覆盖保护,不能只替换目录字符串;本批不是固定产物对齐完成证明。 + +## Lightmap 资产规则(既有实现,按上述最新决定逐步替换) Lightmap 按每次烘焙的 operation UUID 输出到独立版本目录(以下为默认路径模板): @@ -354,9 +364,9 @@ LFX_Terrain_0000.png ``` - Mesh 与 Terrain 使用独立的类型和索引映射,避免两者均从索引 0 开始时串绑贴图。 -- 每次成功烘焙创建新的 URL/Asset UUID,不覆盖任何已发布版本。Undo 恢复旧纹理引用时,旧 PNG 像素仍可用;saveScene:false 的新结果也不会改写磁盘旧场景依赖的贴图。 +- 当前发布阶段仍创建新的 URL/Asset UUID,不直接覆盖已发布像素。保存确认后精确删除旧产物,不再供旧结果 Undo 使用;saveScene:false 的新结果不会改写或删除磁盘旧场景依赖的贴图。固定产物布局仍待接入。 - 旧版平铺目录中的 PNG/`.meta` 原样保留,不自动迁移、不复用其 UUID。调用方必须使用返回的 textureUrls 或真实绑定查询,不拼接固定文件路径。 -- 历史版本暂不自动回收,因此磁盘占用随烘焙次数增加。不能只按“当前场景没绑定”删除旧版本,Undo、其他场景或磁盘已保存版本可能仍在引用。 +- 旧产物从场景归属记录及替换前实时绑定收集,不扫描目录猜测归属。成功保存的 Bake 和删除模式 Clear 会清理无引用候选;引用保留/失败项可以重试。旧版已解绑且从未记录归属的资产不自动猜测删除,空目录暂不删除。 - 导入后将 `fixAlphaTransparencyArtifacts` 设置为 `false`,再加载 Texture2D 子资源并绑定。 - 原生提交确认前的导入/加载失败尝试回滚本次新目录;提交确认后不再删除产物。应用失败恢复旧绑定,保存失败保留已录制结果,规则见“提交与保存失败”。旧版本目录不受影响。 - 成功、失败、取消和超时进入 workspace 清理;回滚或 Asset DB 刷新失败时保留备份和互斥以便恢复,不能宣称所有错误都会完成清理。 @@ -394,10 +404,12 @@ LightFX 当前可能输出 Creator 历史协议版本。解析器只接受已知 Bake 和非删除模式 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与结果修改的 MeshRenderer/Terrain 组件(同一 Terrain 多 block 去重)及 Scene 标记,不能只录制不递归的 Scene 根节点;旧引擎缺类型的空纹理引用也会保留在快照中。成功自动保存后以该记录作为保存点;saveScene:false 不隐式保存场景。`deleteAssets:true` 是例外:保存成功后只取消本次 Clear 录制,不清空整个 Scene Undo/Redo 历史。恢复快照时使 Clear 前的所有烘焙绑定、UV 和标志失效,而节点移动、其他组件参数及探针系数仍按原历史恢复;即使旧纹理因其他引用保留,也不通过本场景旧快照恢复其烘焙效果。Clear 后新生成的烘焙记录仍可撤销。没有删除候选或全部资产保留时同样推进结果代次而保留普通历史。失败必须区分原生提交前、结果应用中和结果录制后保存阶段,不能对所有错误统一恢复绑定或删除资产,详见“提交与保存失败”。 -绑定历史与资产版本分别保留:新版本输出不会覆盖旧 PNG,普通 Bake/Clear 的 Undo 通过旧 UUID 恢复旧纹理/UV/场景标记。此前旧版本 CLI 已覆盖丢失的像素无法靠此修复找回。`deleteAssets:true` 只处理 Clear 前实际绑定、且可验证为不可变 LightFX 版本产物的根贴图 UUID;不会删除整个目录。当前实时场景或其他磁盘资产仍引用的贴图会保留,删除操作不可撤销。 +成功 Lightmap Bake 使先前结果历史失效,保存确认后再清理旧像素;本次结果 Redo、普通属性和探针 SH 历史保留。非删除 Clear 仍可撤销恢复未失效的当前结果。`deleteAssets:true` 合并场景归属记录与实际绑定中可验证的 LightFX 根贴图 UUID,不删除整个目录;实时场景或其他磁盘资产仍引用的贴图保留并报告,删除不可撤销。此前已丢失的像素无法靠此修复找回。 ## 验证范围 +最新实现 `02f099e7`:成功保存后的旧产物精确清理和旧结果历史失效已接通。先 `tsc -b`/Scene 与 editor-extends 构建,后定点 5 套/120 项、扩展 29 套/472 项通过(`/tmp/pink-rebake-cleanup-final-tests.log`);定点 ESLint 无代码错误,已有配置提示保留。新增测试核对真实临时文件、Host 归属、保存失败/结果不明、当前有效 Redo 和普通历史,不等同实机。以下独立版本完整 Undo 的旧实机记录只作为历史证据,不能作为最新策略验收。 + 2026-09-11 产品对齐补充:Lightmap 日志保留真实 Log/Progress 顺序,原生结束后在临时目录清理前读取 `lfx.log`,补充真实 Mesh/Terrain 输出索引与 UV。日志最多 128 条、每条 2048 字符,原生日志文件读取上限 256 KiB,超限明确提示,缺失日志不伪造成几何统计,也不让烘焙失败。探针日志行为保持原状。当前固定产物布局对齐尚未实施,日志与历史修复不代表资源删除实机问题已通过。 当前实现已经验证: From ca2ecb878d3b5addf83162a99a3a7f3864c7252e Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 21:29:55 +0800 Subject: [PATCH 50/64] =?UTF-8?q?fix/=20=E4=BF=9D=E6=8A=A4=E9=9D=9E?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E8=B5=84=E4=BA=A7=E7=A7=BB=E5=8A=A8=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=97=B6=E7=9A=84=E5=8E=9F=E5=A7=8B=E5=85=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/assets/manager/filesystem.ts | 14 +++++- .../assets/test/move-source-failure.test.ts | 49 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 src/core/assets/test/move-source-failure.test.ts diff --git a/src/core/assets/manager/filesystem.ts b/src/core/assets/manager/filesystem.ts index 230ae9532..5ad13d2d5 100644 --- a/src/core/assets/manager/filesystem.ts +++ b/src/core/assets/manager/filesystem.ts @@ -143,8 +143,17 @@ export async function moveAssetSource(source: string, target: string, options?: try { if (!Utils.Path.contains(source, target)) { - await renamePath(source + '.meta', target + '.meta', { overwrite: true }); - await renamePath(source, target, renameOptions); + await renamePath(source + '.meta', target + '.meta', renameOptions); + try { + await renamePath(source, target, renameOptions); + } catch (error) { + // Keep the original UUID when a non-overwriting source move fails. + // Propagate failure before Asset DB refresh can generate a replacement meta. + if (!renameOptions.overwrite && existsSync(source) && !existsSync(target)) { + await renamePath(target + '.meta', source + '.meta', { overwrite: false }); + } + throw error; + } return; } @@ -168,5 +177,6 @@ export async function moveAssetSource(source: string, target: string, options?: } catch (error) { console.error(`asset db moveFile from ${source} -> ${target} fail!`); console.error(error); + if (!renameOptions.overwrite) throw error; } } diff --git a/src/core/assets/test/move-source-failure.test.ts b/src/core/assets/test/move-source-failure.test.ts new file mode 100644 index 000000000..75cf912bf --- /dev/null +++ b/src/core/assets/test/move-source-failure.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, outputFile, readFile, pathExists, remove, move } from 'fs-extra'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +jest.mock('../asset-config', () => ({ __esModule: true, default: { data: {} } })); +jest.mock('../../base/utils', () => ({ __esModule: true, default: { Path: { contains: () => false } } })); +import { moveAssetSource, resetFileSystemProvider, setFileSystemProvider } from '../manager/filesystem'; + +describe('non-overwriting asset source move failure', () => { + let root: string, source: string, target: string; + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'asset-move-failure-')); + source = join(root, 'source.png'); + target = join(root, 'output.png'); + await outputFile(source, 'new pixels'); + await outputFile(`${source}.meta`, '{"uuid":"original"}'); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(async () => { + resetFileSystemProvider(); + jest.restoreAllMocks(); + await remove(root); + }); + it('restores metadata and rejects before Asset DB can refresh a failed PNG move', async () => { + setFileSystemProvider({ rename: async (from, to, options) => { + if (from === source) throw new Error('PNG move denied'); + await move(from, to, { overwrite: !!options?.overwrite }); + } }); + await expect(moveAssetSource(source, target, { overwrite: false })).rejects.toThrow('PNG move denied'); + expect(await readFile(source, 'utf8')).toBe('new pixels'); + expect(await readFile(`${source}.meta`, 'utf8')).toBe('{"uuid":"original"}'); + expect(await pathExists(`${target}.meta`)).toBe(false); + expect(await pathExists(target)).toBe(false); + }); + it('does not overwrite target metadata that appears before the move', async () => { + await outputFile(`${target}.meta`, 'unrelated'); + await expect(moveAssetSource(source, target, { overwrite: false })).rejects.toThrow(); + expect(await readFile(`${target}.meta`, 'utf8')).toBe('unrelated'); + expect(await readFile(`${source}.meta`, 'utf8')).toBe('{"uuid":"original"}'); + expect(await pathExists(source)).toBe(true); + }); + it('still moves both files with their UUID on success', async () => { + await moveAssetSource(source, target, { overwrite: false }); + expect(await readFile(target, 'utf8')).toBe('new pixels'); + expect(await readFile(`${target}.meta`, 'utf8')).toBe('{"uuid":"original"}'); + expect(await pathExists(source)).toBe(false); + expect(await pathExists(`${source}.meta`)).toBe(false); + }); +}); From 8183dd2a45675866202de1ada14b811e522d8e4d Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 21:45:44 +0800 Subject: [PATCH 51/64] =?UTF-8?q?feat/=20=E5=B0=86=E5=B7=B2=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=85=89=E7=85=A7=E8=B4=B4=E5=9B=BE=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=88=B0=E5=9B=BA=E5=AE=9A=E8=BE=93=E5=87=BA=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/scene/common/lightfx-host.ts | 7 ++ .../scene/main-process/lightfx-bake-host.ts | 49 ++++++++-- .../main-process/lightfx/asset-publication.ts | 86 +++++++++++++++++ .../service/baking/lightfx/baker.ts | 4 + .../service/baking/lightfx/host.ts | 1 + .../scene-process/service/lightmap-bake.ts | 10 +- .../test/lightfx-asset-publication.test.ts | 93 +++++++++++++++++++ .../scene/test/lightfx-asset-versions.test.ts | 35 ++++++- src/core/scene/test/lightfx-bake-host.test.ts | 4 +- .../test/lightfx-result-failures.test.ts | 3 +- .../test/lightmap-result-recording.test.ts | 22 ++++- 11 files changed, 294 insertions(+), 20 deletions(-) create mode 100644 src/core/scene/main-process/lightfx/asset-publication.ts create mode 100644 src/core/scene/test/lightfx-asset-publication.test.ts diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts index 25486f09f..0a1c08f6c 100644 --- a/src/core/scene/common/lightfx-host.ts +++ b/src/core/scene/common/lightfx-host.ts @@ -22,6 +22,8 @@ export interface ILightFXHostCapabilities { lightmapAssetCleanupVersion?: 1; /** Supports exact cleanup inside the owning Bake transaction after Scene confirms saving. */ lightmapRebakeCleanupVersion?: 1; + /** Post-save, UUID-preserving relocation into the current fixed output directory. */ + lightmapPublicationVersion?: 1; /** Version 1 requires the exact native operation, target and scene reservation to cancel. */ cancelOwnershipVersion?: 1; diagnosticsVersion?: 1; @@ -112,6 +114,10 @@ export interface ILightFXOperationOptions { operationId: string; } +export interface IPublishLightmapAssetsOptions extends ILightFXOperationOptions { + transactionId: string; +} + export interface ICancelLightFXOperationOptions extends ILightFXOperationOptions { target: LightFXBakeTarget; transactionId?: string; @@ -169,6 +175,7 @@ export interface ILightFXBakeHostService { appendInput(options: IAppendLightFXInputOptions): Promise; run(options: IRunLightFXBakeOptions): Promise; commit(options: ILightFXOperationOptions): Promise; + publishLightmapAssets(options: IPublishLightmapAssetsOptions): Promise<{ textureUrls: string[] }>; rollback(options: ILightFXOperationOptions): Promise; cancel(options?: ICancelLightFXOperationOptions): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }>; removeLightmapAssets(options: IRemoveLightmapAssetsOptions): Promise; diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index 1b29d56de..9898a8f60 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -35,10 +35,12 @@ import type { ILightFXSceneOperationToken, ICancelLightFXOperationOptions, ILightFXDiagnostics, + IPublishLightmapAssetsOptions, } from '../common/lightfx-host'; import { assetManager } from '../../assets'; import { LightmapAssetTransaction } from './lightfx/asset-transaction'; import { LightmapAssetRecord } from './lightfx/asset-record'; +import { isLightmapTextureUrl, publishLightmapTextures, removeEmptyLightmapVersion } from './lightfx/asset-publication'; import { decodeLightFXOutput } from './lightfx/output'; import { LightFXProcess } from './lightfx/process'; @@ -64,6 +66,7 @@ interface LightFXHostOperation { assets: LightmapAssetTransaction | null; assetRecord?: LightmapAssetRecord; recordedTextureUuids?: string[]; + publicationRootUrl: string; cleanupPromise: Promise | null; expiryTimer: NodeJS.Timeout | null; terminalState: OperationTerminalState | null; @@ -102,11 +105,14 @@ export class LightFXBakeHost implements ILightFXBakeHostService { private operation: LightFXHostOperation | null = null; private readonly completedOperations = new Map(); private readonly diagnostics = new Map(); - private sceneOperation: (IReserveLightFXSceneOperationOptions & ILightFXSceneOperationToken & { nativeStarted: boolean; nativeCommitted: boolean; removingAssets: boolean }) | null = null; + private sceneOperation: (IReserveLightFXSceneOperationOptions & ILightFXSceneOperationToken & { + nativeStarted: boolean; nativeCommitted: boolean; removingAssets: boolean; + publication?: { operationId: string; textureUuids: string[]; stagingUrl: string; rootUrl: string }; + }) | null = null; private readonly releasedSceneOperations = new Set(); public async queryCapabilities(): Promise { - return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; + return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; } public async queryDiagnostics(options: ICancelLightFXOperationOptions): Promise { @@ -268,8 +274,8 @@ export class LightFXBakeHost implements ILightFXBakeHostService { ); const tmpDir = join(workspace, 'tmp'); const outputDir = join(workspace, 'output'); - // Published textures are immutable: existing saved scenes and Undo - // records may still refer to any earlier bake, including legacy files. + // Import the new result separately until the scene save is confirmed. + // Fixed publication follows exact cleanup; this is not an Undo pixel archive. const version = `bake-${operationId}`; const parentUrl = options.outputUrl ?? `db://assets/${options.sceneName}/lightmap`; const parentDir = join(assetRoot, parentUrl.slice('db://assets'.length)); @@ -285,6 +291,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { outputDir, targetDir, targetUrl, + publicationRootUrl: options.outputUrl && options.outputUrl !== 'db://assets' ? options.outputUrl : 'db://assets/LightFX', refreshUrl: options.outputUrl ?? `db://assets/${options.sceneName}`, inputBytes: 0, inputWritePromise: Promise.resolve(), @@ -442,7 +449,13 @@ export class LightFXBakeHost implements ILightFXBakeHostService { } // This synchronous decision is the linearization point shared with cancellation and expiry. this.decideTerminalState(operation, 'committed'); - if (this.sceneOperation) this.sceneOperation.nativeCommitted = true; + if (this.sceneOperation) { + this.sceneOperation.nativeCommitted = true; + if (operation.target === 'lightmap' && operation.recordedTextureUuids) { + this.sceneOperation.publication = { operationId: operation.id, textureUuids: operation.recordedTextureUuids, + stagingUrl: operation.targetUrl, rootUrl: operation.publicationRootUrl }; + } + } try { await this.cleanup(operation, false); } catch (error) { @@ -479,6 +492,22 @@ export class LightFXBakeHost implements ILightFXBakeHostService { await this.cleanup(operation, true); } + public async publishLightmapAssets(options: IPublishLightmapAssetsOptions): Promise<{ textureUrls: string[] }> { + this.validateSceneOperation(options?.transactionId, 'lightmap', 'bake'); + const owner = this.sceneOperation; + const publication = owner?.publication; + if (!owner?.nativeCommitted || !publication || publication.operationId !== options?.operationId) { + throw new Error('Lightmap publication requires its committed Bake ownership.'); + } + if (this.operation || owner.removingAssets) throw new Error('Lightmap asset publication or cleanup is already in progress.'); + owner.removingAssets = true; + try { + return { textureUrls: await publishLightmapTextures(this.queryAssetRoot(), publication.textureUuids, publication.stagingUrl, publication.rootUrl) }; + } finally { + owner.removingAssets = false; + } + } + public async cancel(options?: ICancelLightFXOperationOptions): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }> { const operation = this.operation; // Missing/late credentials are a no-op, never a request to cancel whoever is now active. @@ -529,16 +558,19 @@ export class LightFXBakeHost implements ILightFXBakeHostService { return uuid; }))]; const record = new LightmapAssetRecord(dirname(this.queryAssetRoot()), sceneUuid); + const recorded = new Set(await record.read()); const infos = new Map(uuids.map(uuid => [uuid, assetManager.queryAssetInfo(uuid)])); - const known = uuids.filter(uuid => isImmutableLightmapTexture(infos.get(uuid)?.url)); + const managed = (uuid: string): boolean => isImmutableLightmapTexture(infos.get(uuid)?.url) + || (recorded.has(uuid) && isLightmapTextureUrl(infos.get(uuid)?.url)); + const known = uuids.filter(managed); // Include legacy currently-bound candidates before deletion so a retained/failed // delete can be retried after the saved scene no longer has any Lightmap binding. if (known.length) await record.add(known); const result: IRemoveLightmapAssetsResult = { deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }; for (const uuid of uuids) { const info = infos.get(uuid); - if (!info || !isImmutableLightmapTexture(info.url)) { - result.failures.push({ uuid, reason: 'Asset is not an immutable LightFX texture.' }); + if (!info || !managed(uuid)) { + result.failures.push({ uuid, reason: 'Asset is not a managed LightFX texture.' }); continue; } try { @@ -561,6 +593,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { throw new Error('Lightmap texture file still exists after asset deletion.'); } result.deletedTextureUuids.push(uuid); + if (info.file) await removeEmptyLightmapVersion(this.queryAssetRoot(), dirname(info.url!)); } catch (error) { result.failures.push({ uuid, reason: error instanceof Error ? error.message : String(error) }); } diff --git a/src/core/scene/main-process/lightfx/asset-publication.ts b/src/core/scene/main-process/lightfx/asset-publication.ts new file mode 100644 index 000000000..8f5d4d040 --- /dev/null +++ b/src/core/scene/main-process/lightfx/asset-publication.ts @@ -0,0 +1,86 @@ +import { lstat, realpath, rmdir } from 'fs/promises'; +import { ensureDir, pathExists } from 'fs-extra'; +import { basename, dirname, isAbsolute, join, relative, sep } from 'path'; +import { assetManager } from '../../../assets'; +import Utils from '../../../base/utils'; + +export function isLightmapTextureUrl(url: string | undefined): boolean { + return !!url?.startsWith('db://assets/') && /^LFX_(?:Mesh|Terrain)_\d{4,}\.png$/.test(url.split('/').at(-1) ?? ''); +} + +/** Reject symlinks (including dangling ones) before creating or moving managed outputs. */ +async function assertAssetPath(assetRoot: string, path: string): Promise { + const local = relative(assetRoot, path); + if (!local || local === '..' || local.startsWith(`..${sep}`) || isAbsolute(local)) { + throw new Error('Lightmap publication must remain inside assets.'); + } + let current = assetRoot; + for (const part of local.split(sep)) { + current = join(current, part); + try { + if ((await lstat(current)).isSymbolicLink()) throw new Error('Lightmap publication does not follow symbolic links.'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + // Validate the existing root as well; descendants above cannot redirect outside it. + await realpath(assetRoot); +} + +/** Removes only a verified empty temporary import directory, never its contents. */ +export async function removeEmptyLightmapVersion(assetRoot: string, url: string): Promise { + const name = url.split('/').at(-1) ?? ''; + if (!url.startsWith('db://assets/') || !name.startsWith('bake-') || !Utils.UUID.isUUID(name.slice(5))) return; + const path = join(assetRoot, url.slice('db://assets/'.length)); + await assertAssetPath(assetRoot, path); + try { + await rmdir(path); + } catch (error) { + if (['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) return; + throw error; + } + // Let Asset DB reconcile its now-missing directory and metadata through the normal importer. + await assetManager.refreshAsset(dirname(url)); +} + +/** Post-save relocation: UUIDs stay valid even if a later move fails. Never overwrite assets. */ +export async function publishLightmapTextures(assetRoot: string, uuids: readonly string[], stagingUrl: string, rootUrl: string): Promise { + if (!rootUrl.startsWith('db://assets') || (rootUrl !== 'db://assets' && !rootUrl.startsWith('db://assets/'))) { + throw new Error('Invalid Lightmap publication directory.'); + } + const outputUrl = `${rootUrl}/output`; + const outputDir = join(assetRoot, outputUrl.slice('db://assets/'.length)); + await assertAssetPath(assetRoot, outputDir); + const files = uuids.map(uuid => { + const info = assetManager.queryAssetInfo(uuid); + if (!info?.file || !isLightmapTextureUrl(info.url)) throw new Error('Published Lightmap texture is missing.'); + const filename = basename(info.file); + const url = `${outputUrl}/${filename}`; + if (info.url !== `${stagingUrl}/${filename}` && info.url !== url) throw new Error('Lightmap publication source no longer belongs to this bake.'); + return { uuid, source: info.url!, sourceFile: info.file, url, file: join(outputDir, filename) }; + }); + if (new Set(files.map(file => file.url)).size !== files.length) throw new Error('Duplicate Lightmap output names.'); + // Preflight every target before the first move. Partial moves still keep their original UUIDs. + for (const file of files) { + await assertAssetPath(assetRoot, file.sourceFile); + await assertAssetPath(assetRoot, `${file.sourceFile}.meta`); + await assertAssetPath(assetRoot, file.file); + await assertAssetPath(assetRoot, `${file.file}.meta`); + if (!(await pathExists(file.sourceFile))) throw new Error('Lightmap publication source file is missing.'); + if (file.source !== file.url && (await pathExists(file.file) || await pathExists(`${file.file}.meta`) || assetManager.queryUUID(file.url))) { + throw new Error(`Lightmap output is occupied; refusing to overwrite: ${file.url}`); + } + } + await ensureDir(outputDir); + await assetManager.refreshAsset(rootUrl); + for (const file of files) { + if (file.source !== file.url) await assetManager.moveAsset(file.source, file.url, { overwrite: false, rename: false }); + const info = assetManager.queryAssetInfo(file.uuid); + if (info?.url !== file.url || assetManager.queryUUID(file.url) !== file.uuid || !(await pathExists(file.file)) + || (file.source !== file.url && await pathExists(file.sourceFile))) { + throw new Error(`Lightmap publication was not confirmed: ${file.url}`); + } + } + await removeEmptyLightmapVersion(assetRoot, stagingUrl); + return files.map(file => file.url); +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/baker.ts b/src/core/scene/scene-process/service/baking/lightfx/baker.ts index 617774973..e4b50999c 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/baker.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/baker.ts @@ -95,6 +95,10 @@ export class LightFXCoordinator { ...(action === 'bake' ? { action } : {}) }); } + publishLightmapAssets(operationId: string): Promise<{ textureUrls: string[] }> { + return lightFXBakeHost.publishLightmapAssets({ operationId, transactionId: lightFXSceneOperation.hostTransactionId }); + } + async cancel(target: LightFXBakeTarget): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }> { const operation = this.operation; if (!operation || operation.target !== target) return { cancelled: false, target: null }; diff --git a/src/core/scene/scene-process/service/baking/lightfx/host.ts b/src/core/scene/scene-process/service/baking/lightfx/host.ts index a87e48c18..aed5ee6bf 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/host.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/host.ts @@ -27,6 +27,7 @@ export const lightFXBakeHost: ILightFXBakeHostService = { appendInput: (options: IAppendLightFXInputOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'appendInput', [options]), run: (options: IRunLightFXBakeOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'run', [options]), commit: (options: ILightFXOperationOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'commit', [options]), + publishLightmapAssets: options => Rpc.getInstance().request('lightFXBakeHost', 'publishLightmapAssets', [options]), rollback: (options: ILightFXOperationOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'rollback', [options]), cancel: (options?: ICancelLightFXOperationOptions): Promise<{ cancelled: boolean; target: 'light-probe' | 'lightmap' | null }> => Rpc.getInstance().request('lightFXBakeHost', 'cancel', [options]), removeLightmapAssets: (options: IRemoveLightmapAssetsOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'removeLightmapAssets', [options]), diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index 8d3c8447e..933519c01 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -46,8 +46,9 @@ export class LightmapBakeService extends BaseService impleme const sceneUrl = await this.querySceneUrl(); // Preflight before native publication or scene mutation, not after a successful save. - if ((await lightFXBakeHost.queryCapabilities())?.lightmapRebakeCleanupVersion !== 1) { - throw new Error('The LightFX host does not support safe Lightmap rebake cleanup.'); + const capabilities = await lightFXBakeHost.queryCapabilities(); + if (capabilities?.lightmapRebakeCleanupVersion !== 1 || capabilities.lightmapPublicationVersion !== 1) { + throw new Error('The LightFX host does not support current Lightmap publication and cleanup. Restart the Cocos host after updating the CLI; reloading only the window may keep the old host.'); } const owned = (await lightFXBakeHost.queryLightmapTextureInfo({ uuids: [], sceneUuid: scene.uuid })).ownedTextureUuids ?? []; const settings = createDefaultLightFXSettings('lightmap'); @@ -121,6 +122,11 @@ export class LightmapBakeService extends BaseService impleme // Explicitly unsaved bakes retain pixels still needed by the saved scene, not for Undo. if (options.saveScene !== false) { await this.cleanupPreviousBake(scene, previousTextureUuids, textures); + try { + output.textureUrls = (await lightFXCoordinator.publishLightmapAssets(output.operationId)).textureUrls; + } catch (error) { + throw new Error(`New Lightmap result is saved and retained; fixed output publication was not completed. ${this.errorMessage(error)}`); + } } this.broadcast('lightfx:bake-end', 'lightmap'); diff --git a/src/core/scene/test/lightfx-asset-publication.test.ts b/src/core/scene/test/lightfx-asset-publication.test.ts new file mode 100644 index 000000000..6573e18a0 --- /dev/null +++ b/src/core/scene/test/lightfx-asset-publication.test.ts @@ -0,0 +1,93 @@ +import { randomUUID } from 'crypto'; +import { mkdtemp, ensureDir, outputFile, readFile, pathExists, move, remove, symlink } from 'fs-extra'; +import { dirname, join } from 'path'; +import { tmpdir } from 'os'; + +const mockAssets = { queryAssetInfo: jest.fn(), queryUUID: jest.fn(), moveAsset: jest.fn(), refreshAsset: jest.fn() }; +jest.mock('../../assets', () => ({ assetManager: mockAssets })); +import { publishLightmapTextures, removeEmptyLightmapVersion } from '../main-process/lightfx/asset-publication'; + +describe('Fixed Lightmap publication after saving', () => { + let project: string, root: string, staging: string; + let infos: Map; + const path = (url: string) => join(root, url.slice('db://assets/'.length)); + beforeEach(async () => { + project = await mkdtemp(join(tmpdir(), 'lightfx-publish-')); + root = join(project, 'assets'); + await ensureDir(root); + staging = `db://assets/staging/bake-${randomUUID()}`; + infos = new Map(); + mockAssets.queryAssetInfo.mockReset().mockImplementation(uuid => infos.get(uuid)); + mockAssets.queryUUID.mockReset().mockImplementation(url => [...infos.values()].find(info => info.url === url)?.uuid); + mockAssets.refreshAsset.mockReset().mockResolvedValue(undefined); + mockAssets.moveAsset.mockReset().mockImplementation(async (source: string, target: string) => { + const info = [...infos.values()].find(item => item.url === source)!; + await move(info.file, path(target), { overwrite: false }); + await move(`${info.file}.meta`, `${path(target)}.meta`, { overwrite: false }); + Object.assign(info, { url: target, file: path(target) }); + }); + }); + afterEach(async () => { await remove(project); }); + async function add(name: string) { + const uuid = randomUUID(), url = `${staging}/${name}`; + await outputFile(path(url), `pixels:${name}`); + await outputFile(`${path(url)}.meta`, JSON.stringify({ uuid })); + infos.set(uuid, { uuid, url, file: path(url) }); + return uuid; + } + + it('moves Mesh and Terrain files without copying old pixels or changing UUIDs, and removes only the empty staging directory', async () => { + const ids = await Promise.all([add('LFX_Mesh_0000.png'), add('LFX_Terrain_0000.png')]); + const result = await publishLightmapTextures(root, ids, staging, 'db://assets/LightFX'); + expect(result).toEqual(['db://assets/LightFX/output/LFX_Mesh_0000.png', 'db://assets/LightFX/output/LFX_Terrain_0000.png']); + for (let i = 0; i < ids.length; i++) { + expect(JSON.parse(await readFile(`${path(result[i])}.meta`, 'utf8')).uuid).toBe(ids[i]); + } + expect(await pathExists(path(staging))).toBe(false); + }); + it.each(['file', 'meta', 'database'])('preflights all targets and refuses a %s collision without moving the first file', async kind => { + const ids = await Promise.all([add('LFX_Mesh_0000.png'), add('LFX_Terrain_0000.png')]); + const target = 'db://assets/LightFX/output/LFX_Terrain_0000.png'; + if (kind === 'database') infos.set('other', { uuid: 'other', url: target, file: path(target) }); + else await outputFile(`${path(target)}${kind === 'meta' ? '.meta' : ''}`, 'unrelated'); + await expect(publishLightmapTextures(root, ids, staging, 'db://assets/LightFX')).rejects.toThrow('occupied'); + expect(mockAssets.moveAsset).not.toHaveBeenCalled(); + expect(await readFile(infos.get(ids[0])!.file, 'utf8')).toBe('pixels:LFX_Mesh_0000.png'); + }); + it('retains resolvable new UUIDs after partial move failure, then can retry without overwriting', async () => { + const ids = await Promise.all([add('LFX_Mesh_0000.png'), add('LFX_Terrain_0000.png')]); + const original = mockAssets.moveAsset.getMockImplementation()!; + mockAssets.moveAsset.mockImplementationOnce(original).mockRejectedValueOnce(new Error('move denied')); + await expect(publishLightmapTextures(root, ids, staging, 'db://assets/LightFX')).rejects.toThrow('move denied'); + expect(ids.map(id => infos.get(id)!.url)).toEqual(['db://assets/LightFX/output/LFX_Mesh_0000.png', `${staging}/LFX_Terrain_0000.png`]); + for (const info of infos.values()) expect(await pathExists(info.file)).toBe(true); + await publishLightmapTextures(root, ids, staging, 'db://assets/LightFX'); + expect(await pathExists(path(staging))).toBe(false); + }); + it('does not trust a successful move response without actual files and UUID mapping', async () => { + const uuid = await add('LFX_Mesh_0000.png'); + mockAssets.moveAsset.mockResolvedValueOnce(undefined); + await expect(publishLightmapTextures(root, [uuid], staging, 'db://assets/LightFX')).rejects.toThrow('not confirmed'); + expect(await pathExists(infos.get(uuid)!.file)).toBe(true); + }); + it.each(['directory', 'dangling-meta'])('rejects %s symlinks without modifying outside files', async kind => { + const uuid = await add('LFX_Mesh_0000.png'); + const outside = join(project, 'outside'); + await ensureDir(outside); + if (kind === 'directory') await symlink(outside, path('db://assets/LightFX')); + else { + const meta = `${path('db://assets/LightFX/output/LFX_Mesh_0000.png')}.meta`; + await ensureDir(dirname(meta)); + await symlink(join(outside, 'absent'), meta); + } + await expect(publishLightmapTextures(root, [uuid], staging, 'db://assets/LightFX')).rejects.toThrow('symbolic'); + expect(mockAssets.moveAsset).not.toHaveBeenCalled(); + }); + it('never deletes a nonempty version folder or a regular output directory', async () => { + await outputFile(path(`${staging}/unrelated.txt`), 'keep'); + await removeEmptyLightmapVersion(root, staging); + await ensureDir(path('db://assets/LightFX/output')); + await removeEmptyLightmapVersion(root, 'db://assets/LightFX/output'); + expect([await readFile(path(`${staging}/unrelated.txt`), 'utf8'), await pathExists(path('db://assets/LightFX/output'))]).toEqual(['keep', true]); + }); +}); diff --git a/src/core/scene/test/lightfx-asset-versions.test.ts b/src/core/scene/test/lightfx-asset-versions.test.ts index ccb54080c..c73507a28 100644 --- a/src/core/scene/test/lightfx-asset-versions.test.ts +++ b/src/core/scene/test/lightfx-asset-versions.test.ts @@ -1,4 +1,4 @@ -import { ensureDir, existsSync, mkdtemp, outputFile, pathExists, readFile, remove, symlink } from 'fs-extra'; +import { ensureDir, existsSync, mkdtemp, outputFile, pathExists, readFile, remove, symlink, move } from 'fs-extra'; import { randomUUID } from 'crypto'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -6,7 +6,7 @@ import { tmpdir } from 'os'; const mockAssets = { queryPath: jest.fn(), refreshAsset: jest.fn(), queryUUID: jest.fn(), queryAssetMeta: jest.fn(() => ({ userData: { fixAlphaTransparencyArtifacts: false } })), - queryAssetInfo: jest.fn(), queryAssetUsers: jest.fn(), removeAsset: jest.fn(), + queryAssetInfo: jest.fn(), queryAssetUsers: jest.fn(), removeAsset: jest.fn(), moveAsset: jest.fn(), }; const mockRun = jest.fn(); jest.mock('../../assets', () => ({ assetManager: mockAssets })); @@ -48,6 +48,7 @@ describe('Immutable Lightmap asset versions', () => { mockAssets.queryUUID.mockImplementation((url: string) => { const existing = [...identities.values()].find(info => info.url === url); if (existing) return existing.uuid; + if (!existsSync(assetPath(url))) return null; const uuid = randomUUID(); identities.set(uuid, { uuid, url, file: assetPath(url) }); return uuid; @@ -63,9 +64,37 @@ describe('Immutable Lightmap asset versions', () => { await remove(`${info.file}.meta`); identities.delete(uuid); }); + mockAssets.moveAsset.mockReset().mockImplementation(async (source: string, target: string) => { + const info = [...identities.values()].find(info => info.url === source)!; + await move(info.file, assetPath(target), { overwrite: false }); + info.url = target; + info.file = assetPath(target); + }); return identities; } + it.each([undefined, 'db://assets', 'db://assets/Chosen'])('publishes fixed current textures with the same UUID and supports Clear after reopening (%s)', async outputUrl => { + const identities = realAssetFiles(), sceneUuid = randomUUID(); + if (outputUrl) await ensureDir(assetPath(outputUrl)); + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const a = await bake('pixels A', outputUrl, sceneUuid, owner.transactionId); + const uuid = [...identities.keys()][0]; + const request = { ...a.token, ...owner }; + await expect(host.publishLightmapAssets(request)).rejects.toThrow('ownership'); + await host.commit(a.token); + await expect(host.publishLightmapAssets({ ...request, operationId: randomUUID() })).rejects.toThrow('ownership'); + const output = await host.publishLightmapAssets(request); + const target = `${outputUrl && outputUrl !== 'db://assets' ? outputUrl : 'db://assets/LightFX'}/output/LFX_Mesh_0000.png`; + expect(output.textureUrls).toEqual([target]); + expect([identities.get(uuid)?.url, await readFile(assetPath(target), 'utf8'), await pathExists(a.path)]).toEqual([target, 'pixels A', false]); + await expect(host.publishLightmapAssets(request)).resolves.toEqual(output); + await host.releaseSceneOperation(owner); + host = new LightFXBakeHost(); + expect((await host.queryLightmapTextureInfo({ sceneUuid, uuids: [] })).ownedTextureUuids).toEqual([uuid]); + const cleared = await host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid] }); + expect([cleared.deletedTextureUuids, await pathExists(assetPath(target))]).toEqual([[uuid], false]); + }); + it('cleans prior files inside the committed Bake reservation without deleting the new result', async () => { const identities = realAssetFiles(), sceneUuid = randomUUID(); const a = await bake('pixels A', undefined, sceneUuid); @@ -245,6 +274,6 @@ describe('Immutable Lightmap asset versions', () => { mockAssets.refreshAsset.mockRejectedValueOnce(new Error('import unavailable')); await expect(bake('pixels B')).rejects.toThrow('import unavailable'); expect(await readFile(a.path, 'utf8')).toBe('pixels A'); - await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }); + await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }); }); }); diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts index 360c364fc..9cd7ba3cb 100644 --- a/src/core/scene/test/lightfx-bake-host.test.ts +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -90,7 +90,7 @@ describe('LightFXBakeHost', () => { }); it('queries protocol and occupancy without reserving, releasing or exposing ownership', async () => { - const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }; + const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }; const busy = { ...idle, busy: true }; await expect(host.queryCapabilities()).resolves.toEqual(idle); const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); @@ -243,7 +243,7 @@ describe('LightFXBakeHost', () => { deletedTextureUuids: [deleted], retainedTextureUuids: [retained], failures: [ - { uuid: invalid, reason: 'Asset is not an immutable LightFX texture.' }, + { uuid: invalid, reason: 'Asset is not a managed LightFX texture.' }, { uuid: failed, reason: 'trash unavailable' }, ], }); diff --git a/src/core/scene/test/lightfx-result-failures.test.ts b/src/core/scene/test/lightfx-result-failures.test.ts index f5457276f..5e10c9df9 100644 --- a/src/core/scene/test/lightfx-result-failures.test.ts +++ b/src/core/scene/test/lightfx-result-failures.test.ts @@ -20,10 +20,11 @@ jest.mock('../scene-process/service/core', () => ({ })); jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { bake: mockBake, commit: mockCommit, rollback: mockRollback, removeLightmapAssets: mockRemoveLightmapAssets, + publishLightmapAssets: async () => ({ textureUrls: ['db://assets/LightFX/output/LFX_Mesh_0000.png'] }), } })); jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { reserveSceneOperation: async () => ({ transactionId: 'owner' }), releaseSceneOperation: async () => undefined, - queryCapabilities: async () => ({ lightmapRebakeCleanupVersion: 1 }), + queryCapabilities: async () => ({ lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1 }), queryLightmapTextureInfo: async () => ({ textures: [], missingTextureUuids: [], ownedTextureUuids: [] }), } })); jest.mock('../scene-process/service/baking/lightfx/settings', () => ({ createDefaultLightFXSettings: () => ({}) })); diff --git a/src/core/scene/test/lightmap-result-recording.test.ts b/src/core/scene/test/lightmap-result-recording.test.ts index 93ee43060..1d8c823de 100644 --- a/src/core/scene/test/lightmap-result-recording.test.ts +++ b/src/core/scene/test/lightmap-result-recording.test.ts @@ -5,7 +5,8 @@ const mockBake = jest.fn(); const mockCommit = jest.fn(); const mockRollback = jest.fn(); const mockRemoveLightmapAssets = jest.fn(); -const mockQueryCapabilities = jest.fn(async (): Promise<{ lightmapAssetCleanupVersion?: 1; lightmapRebakeCleanupVersion?: 1 }> => ({ lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1 })); +const mockPublishLightmapAssets = jest.fn(); +const mockQueryCapabilities = jest.fn(async (): Promise<{ lightmapAssetCleanupVersion?: 1; lightmapRebakeCleanupVersion?: 1; lightmapPublicationVersion?: 1 }> => ({ lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1 })); const mockUndo = { beginRecording: jest.fn(() => 'recording'), endRecording: jest.fn(async () => undefined), @@ -22,7 +23,7 @@ jest.mock('../scene-process/service/core', () => ({ BaseService: class { broadcast() {} }, register: () => () => undefined, Service: { Undo: mockUndo, Editor: { save: mockSave, querySceneSerializedData: mockQuerySceneSerializedData }, Engine: { repaintInEditMode: async () => undefined } }, })); -jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { bake: mockBake, commit: mockCommit, rollback: mockRollback, removeLightmapAssets: mockRemoveLightmapAssets } })); +jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { bake: mockBake, commit: mockCommit, rollback: mockRollback, removeLightmapAssets: mockRemoveLightmapAssets, publishLightmapAssets: mockPublishLightmapAssets } })); jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { queryLightmapTextureInfo: mockQueryTextureInfo, queryCapabilities: mockQueryCapabilities, @@ -60,7 +61,8 @@ describe('Lightmap result recording targets', () => { beforeEach(() => { jest.clearAllMocks(); mockBake.mockReset(); - mockQueryCapabilities.mockResolvedValue({ lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1 }); + mockQueryCapabilities.mockResolvedValue({ lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1 }); + mockPublishLightmapAssets.mockReset().mockResolvedValue({ textureUrls: ['db://assets/LightFX/output/LFX_Mesh_0000.png'] }); mockQuerySceneSerializedData.mockResolvedValue('[]'); mockQueryTextureInfo.mockReset().mockResolvedValue({ textures: [], missingTextureUuids: [] }); mockRemoveLightmapAssets.mockResolvedValue({ deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }); @@ -92,9 +94,11 @@ describe('Lightmap result recording targets', () => { it('deletes previous and older unbound products only after the new result is saved', async () => { const f = fixture(); mockQueryTextureInfo.mockResolvedValueOnce({ textures: [], missingTextureUuids: [], ownedTextureUuids: ['older-A', 'old-texture', 'new-texture'] }); - await f.service.bake(); + const result = await f.service.bake(); + expect(result.textureUrls).toEqual(['db://assets/LightFX/output/LFX_Mesh_0000.png']); expect(mockRemoveLightmapAssets).toHaveBeenCalledWith('scene', ['older-A', 'old-texture'], 'bake'); expect(mockSave.mock.invocationCallOrder[0]).toBeLessThan(mockRemoveLightmapAssets.mock.invocationCallOrder[0]); + expect(mockRemoveLightmapAssets.mock.invocationCallOrder[0]).toBeLessThan(mockPublishLightmapAssets.mock.invocationCallOrder[0]); expect(mockUndo.clearHistory).not.toHaveBeenCalled(); }); it('keeps disk-dependent old pixels for an explicitly unsaved Bake, without retaining old result history', async () => { @@ -105,8 +109,18 @@ describe('Lightmap result recording targets', () => { await f.service.bake({ saveScene: false }); expect(mockSave).not.toHaveBeenCalled(); expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + expect(mockPublishLightmapAssets).not.toHaveBeenCalled(); expect(deletedLightmapAssets.filter(f.scene, old, 'dump').value.texture.value.uuid).toBe(''); }); + it('does not revert saved bindings or remove new files on fixed publication failure', async () => { + const f = fixture(); + mockPublishLightmapAssets.mockRejectedValueOnce(new Error('target occupied')); + await expect(f.service.bake()).rejects.toThrow('fixed output publication was not completed'); + expect(mockSave).toHaveBeenCalledTimes(1); + expect(mockRollback).not.toHaveBeenCalled(); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(f.model._updateLightmap).toHaveBeenLastCalledWith(f.texture, 0.1, 0.2, 0.3, 0.4); + }); it('retains and reports old products referenced by non-Lightmap scene fields', async () => { const f = fixture(); mockQuerySceneSerializedData.mockResolvedValueOnce(JSON.stringify({ custom: { __uuid__: 'old-texture@f9941' } })); From fe01d76a4fdea1b4450a3a3e87f466d3156f8fe9 Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 22:04:48 +0800 Subject: [PATCH 52/64] =?UTF-8?q?docs/=20=E8=AE=B0=E5=BD=95=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E8=B4=B4=E5=9B=BE=E5=8F=91=E5=B8=83=E4=B8=8E=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E6=B8=85=E7=90=86=E5=AE=9E=E6=9C=BA=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 848ebe828..181e3f9ac 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -344,17 +344,21 @@ Scene 侧 `LightProbeBake.cancel()`/`LightmapBake.cancel()` 只取消本 rende 本批补齐成功重烘焙的收尾链路:修改场景前校验 Host 的内部 `lightmapRebakeCleanupVersion === 1` 并读取旧候选;新结果应用、录制和保存确认后,使旧 Lightmap 历史失效(保留本次新结果的 Redo 和普通历史),检查实时剩余引用并清理旧候选。Host 只接受仍持有正确 Bake reservation 且原生已 commit 的 `action:bake` 清理。当前新结果、其他字段/场景/材质引用必须保留。删除失败、引用保留或回应不明时返回包含 `New Lightmap result is saved and retained` 的错误,保留已完成的新结果并明确报告,不再恢复旧内存冒充回滚;不能把它解释成 Bake 没有修改场景。 -`saveScene:false` 不授权删除磁盘已保存场景仍依赖的贴图,本批不隐式保存,也不删除旧产物;成功后的旧结果历史仍失效。保存失败/取消/应用失败不触发旧产物清理。下一次成功保存的 Bake 或删除模式 Clear 可重试已记录产物。固定 `LightFX/output` 与 `tmp/lfx.in`/`output/lfx.out`/`lfx.log` 发布另行接入,须先解决发布与保存之间的覆盖保护,不能只替换目录字符串;本批不是固定产物对齐完成证明。 +`saveScene:false` 不授权删除磁盘已保存场景仍依赖的贴图,不隐式保存,也不删除旧产物或固定发布;成功后的旧结果历史仍失效。保存失败/取消/应用失败不触发旧产物清理。下一次成功保存的 Bake 或删除模式 Clear 可重试已记录产物。`31598b0a` 已接入保存后的固定 PNG 发布;`tmp/lfx.in`/`output/lfx.out`/`lfx.log` 固定发布仍待接入,不宣称完整产物已经对齐。 -## Lightmap 资产规则(既有实现,按上述最新决定逐步替换) +## Lightmap 资产规则 -Lightmap 按每次烘焙的 operation UUID 输出到独立版本目录(以下为默认路径模板): +固定贴图发布的最小接入(实施前记录):继续使用独立临时导入目录完成纹理加载和场景保存;保存确认、旧产物清理完成后,通过 Asset DB 保留 UUID 移动到默认 `db://assets/LightFX/output`,指定 `outputUrl` 时移动到 `/output`。固定发布由原 Bake reservation 和实际 operation ID 校验,不接受任意外部 UUID。所有目标先检查冲突,逐项移动后核对 UUID、URL 和磁盘源/目标;不覆盖同名资产、不先复用旧 UUID。失败保留已保存的新结果位置,不删除新贴图。只用非递归空目录删除收敛已清空的 `bake-UUID`,其他文件存在时保留。`saveScene:false` 暂不固定发布,保护磁盘旧引用。本批不宣称 `lfx.in/out/log` 配套文件已经对齐。 + +Lightmap 先按每次烘焙的 operation UUID 导入到独立暂存目录(以下为省略 `outputUrl` 时的模板),这不是成功后保留的历史版本: ```text db://assets//lightmap/bake-/ ``` -指定 `outputUrl` 时改为 `/bake-/`,例如 `db://assets/烘焙结果 Room A`。目录必须已存在且真实路径位于当前项目 assets 内;不接受任意磁盘路径、路径穿越或指向 assets 外的符号链接。参数仅改变本次输出位置,不自动保存为场景设置。Scene 的 `queryCapabilities().outputDirectory === true` 来自实际 Host 的 `lightmapOutputDirectory` 支持位;旧 Host 不支持时明确报错,不忽略选择后写入默认目录。省略参数仍沿用原路径。 +指定 `outputUrl` 时暂存到 `/bake-/`,例如 `db://assets/烘焙结果 Room A`。选择目录必须已存在且真实路径位于当前项目 assets 内;不接受任意磁盘路径、路径穿越或指向 assets 外的符号链接。参数仅改变本次输出位置,不自动保存为场景设置。Scene 的 `queryCapabilities().outputDirectory === true` 来自实际 Host 的 `lightmapOutputDirectory` 支持位;旧 Host 不支持时明确报错,不忽略选择后写入默认目录。 + +保存与旧图清理成功后,当前 PNG 保留 UUID 移动到 `db://assets/LightFX/output`;选择 `db://assets` 与省略参数相同,选择子目录则发布到 `/output`。固定发布额外要求内部 Host 的 `lightmapPublicationVersion === 1`。更新 CLI 后若出现能力不支持错误,需要重启实际 Cocos Host;单独 Reload Window 可能仍连接旧 Host。 典型文件包括: @@ -364,9 +368,9 @@ LFX_Terrain_0000.png ``` - Mesh 与 Terrain 使用独立的类型和索引映射,避免两者均从索引 0 开始时串绑贴图。 -- 当前发布阶段仍创建新的 URL/Asset UUID,不直接覆盖已发布像素。保存确认后精确删除旧产物,不再供旧结果 Undo 使用;saveScene:false 的新结果不会改写或删除磁盘旧场景依赖的贴图。固定产物布局仍待接入。 +- 每次生成新 Asset UUID,不直接覆盖已发布像素。保存确认后精确删除旧产物,再把新图移动到固定 URL,不再供旧结果 Undo 使用;`saveScene:false` 的新结果留在独立暂存位置,不会改写或删除磁盘旧场景依赖的贴图。目标仍被占用时明确报错,不覆盖。 - 旧版平铺目录中的 PNG/`.meta` 原样保留,不自动迁移、不复用其 UUID。调用方必须使用返回的 textureUrls 或真实绑定查询,不拼接固定文件路径。 -- 旧产物从场景归属记录及替换前实时绑定收集,不扫描目录猜测归属。成功保存的 Bake 和删除模式 Clear 会清理无引用候选;引用保留/失败项可以重试。旧版已解绑且从未记录归属的资产不自动猜测删除,空目录暂不删除。 +- 旧产物从场景归属记录及替换前实时绑定收集,不扫描目录猜测归属。成功保存的 Bake 和删除模式 Clear 会清理无引用候选;引用保留/失败项可以重试。旧版已解绑且从未记录归属的资产不自动猜测删除;已清空的 `bake-UUID` 目录只做非递归删除并刷新 Asset DB,含其他内容的目录保留。 - 导入后将 `fixAlphaTransparencyArtifacts` 设置为 `false`,再加载 Texture2D 子资源并绑定。 - 原生提交确认前的导入/加载失败尝试回滚本次新目录;提交确认后不再删除产物。应用失败恢复旧绑定,保存失败保留已录制结果,规则见“提交与保存失败”。旧版本目录不受影响。 - 成功、失败、取消和超时进入 workspace 清理;回滚或 Asset DB 刷新失败时保留备份和互斥以便恢复,不能宣称所有错误都会完成清理。 @@ -408,9 +412,19 @@ Bake 和非删除模式 Clear 的结果作为单次 Undo 记录。Lightmap 明 ## 验证范围 -最新实现 `02f099e7`:成功保存后的旧产物精确清理和旧结果历史失效已接通。先 `tsc -b`/Scene 与 editor-extends 构建,后定点 5 套/120 项、扩展 29 套/472 项通过(`/tmp/pink-rebake-cleanup-final-tests.log`);定点 ESLint 无代码错误,已有配置提示保留。新增测试核对真实临时文件、Host 归属、保存失败/结果不明、当前有效 Redo 和普通历史,不等同实机。以下独立版本完整 Undo 的旧实机记录只作为历史证据,不能作为最新策略验收。 +固定 PNG 发布依赖补充:Asset DB 的普通非覆盖移动原先先移 `.meta`,再移源文件,失败后仍吞错并刷新。仅对非覆盖移动补充错误传播;普通同级移动若源文件尚在、目标文件尚未生成,则无覆盖地回放已移动的元数据,阻止后续刷新生成不同 UUID。覆盖模式不在本次修改范围。该最小共用依赖必须用真实文件与故障注入验证,不能只靠 `moveAsset()` resolve 判成功。 + +默认入口补充:PinK 目录选择器总会传入 `outputUrl`,默认选中 `db://assets` 与省略参数同样发布到 `db://assets/LightFX/output`;选择 assets 内子目录才使用 `/output`。这保证直接接受目录选择器默认值也得到 Creator 风格目录,不要求 UI 绕过既有选择入口。 + +本轮首次 UI 验证发现 PinK 桥接仍硬编码 `saveScene:false`,实际跳过以上发布和旧图清理,虽然面板提示生成成功。该次结果不作为通过证据。PinK 面板入口改为显式保存,并在生成前告知保存/替换语义;CLI 非 UI 调用显式传 `saveScene:false` 仍保持不保存、不删除磁盘旧依赖的安全契约。证据 `/tmp/codex-ui-verifier.aG5Cnt`。 + +固定 PNG 发布 `31598b0a`、非覆盖移动保护 `60f9a975`:先 `tsc -b` 和 Scene/editor-extends 构建,再 **32 套/524 项**通过(`/tmp/pink-fixed-output-final2-tests.log`);定点 ESLint 无代码错误,保留已有 unused catch 和配置警告。PinK `17a0a25b7cb` 接通面板保存式 Bake,客户端类型检查/构建和扩展构建后,15 项 Electron 桥接测试、69 项扩展宿主测试及 1 套编译面板测试通过。实机结果另行记录,不以这些自动测试代替。 + +本轮最终隔离实机 `/tmp/codex-ui-verifier.4fxdtg`:真实面板 128→256 两次 Bake 均发布到 `LightFX/output`,由原生实际打包产生 3→2 张 PNG;旧 PNG/meta 和已空暂存版本目录实际删除、保存场景和运行时 UUID 一致。真实 Clear 后当前 PNG/meta 删除、Mesh 和两个 Terrain block 纹理/UV 清空,节点 X=1 不回退;Scene Undo 节点/最近一条 Bake、Redo Bake/节点不恢复旧图。真实保存、关闭 Scene 标签并从 Assets 重开后仍无绑定/缺图,X=1、dirty=false,43 点完整 SH 哈希不变。主 agent 准备隔离工程与新 Host 后交全局 `ui_verifier` 控制,并独立核对原始数据。旧版未记录归属且已解绑文件未猜删;没有验证所有更早历史、异常/取消/外部引用、禁用 Terrain 或保留历史软重载。原生配套文件固定发布及 Creator 同场景日志/预览对照仍待完成,不将本主链路称为全量产品对齐。已有 dump null/argv.json 告警不宣称消除。 + +前一批 `02f099e7`:成功保存后的旧产物精确清理和旧结果历史失效已接通。先 `tsc -b`/Scene 与 editor-extends 构建,后定点 5 套/120 项、扩展 29 套/472 项通过(`/tmp/pink-rebake-cleanup-final-tests.log`);定点 ESLint 无代码错误,已有配置提示保留。新增测试核对真实临时文件、Host 归属、保存失败/结果不明、当前有效 Redo 和普通历史,不等同实机。以下独立版本完整 Undo 的旧实机记录只作为历史证据,不能作为最新策略验收。 -2026-09-11 产品对齐补充:Lightmap 日志保留真实 Log/Progress 顺序,原生结束后在临时目录清理前读取 `lfx.log`,补充真实 Mesh/Terrain 输出索引与 UV。日志最多 128 条、每条 2048 字符,原生日志文件读取上限 256 KiB,超限明确提示,缺失日志不伪造成几何统计,也不让烘焙失败。探针日志行为保持原状。当前固定产物布局对齐尚未实施,日志与历史修复不代表资源删除实机问题已通过。 +2026-09-11 产品对齐补充:Lightmap 日志保留真实 Log/Progress 顺序,原生结束后在临时目录清理前读取 `lfx.log`,补充真实 Mesh/Terrain 输出索引与 UV。日志最多 128 条、每条 2048 字符,原生日志文件读取上限 256 KiB,超限明确提示,缺失日志不伪造成几何统计,也不让烘焙失败。探针日志行为保持原状。此处日志修复不代表原生配套文件已固定发布,也不代替资源删除实机证据。 当前实现已经验证: From bbb5140ea480d1736a4ce6aac6139541e395990b Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 22:44:06 +0800 Subject: [PATCH 53/64] =?UTF-8?q?feat/=20=E5=8F=91=E5=B8=83=E5=B9=B6?= =?UTF-8?q?=E7=B2=BE=E7=A1=AE=E6=B8=85=E7=90=86=E5=85=89=E7=85=A7=E7=83=98?= =?UTF-8?q?=E7=84=99=E5=8E=9F=E7=94=9F=E9=85=8D=E5=A5=97=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/scene/common/lightfx-host.ts | 4 + .../scene/main-process/lightfx-bake-host.ts | 64 ++++++++++++---- .../main-process/lightfx/asset-publication.ts | 27 +++++-- .../main-process/lightfx/asset-record.ts | 45 ++++++++---- .../scene-process/service/lightmap-bake.ts | 28 +++---- .../test/lightfx-asset-publication.test.ts | 27 +++++++ .../scene/test/lightfx-asset-record.test.ts | 13 +++- .../scene/test/lightfx-asset-versions.test.ts | 73 ++++++++++++++++++- src/core/scene/test/lightfx-bake-host.test.ts | 2 +- .../test/lightfx-result-failures.test.ts | 2 +- .../test/lightmap-result-recording.test.ts | 28 +++++-- 11 files changed, 253 insertions(+), 60 deletions(-) diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts index 0a1c08f6c..e09bb8db0 100644 --- a/src/core/scene/common/lightfx-host.ts +++ b/src/core/scene/common/lightfx-host.ts @@ -24,6 +24,8 @@ export interface ILightFXHostCapabilities { lightmapRebakeCleanupVersion?: 1; /** Post-save, UUID-preserving relocation into the current fixed output directory. */ lightmapPublicationVersion?: 1; + /** Stages, publishes and precisely cleans up native lfx.in/out/log assets. */ + lightmapAuxiliaryAssetsVersion?: 1; /** Version 1 requires the exact native operation, target and scene reservation to cancel. */ cancelOwnershipVersion?: 1; diagnosticsVersion?: 1; @@ -135,6 +137,8 @@ export interface IRemoveLightmapAssetsOptions { export interface IRemoveLightmapAssetsResult { deletedTextureUuids: string[]; retainedTextureUuids: string[]; + deletedAuxiliaryAssetUuids?: string[]; + retainedAuxiliaryAssetUuids?: string[]; failures: Array<{ uuid: string; reason: string }>; } diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index 9898a8f60..d619adfa5 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -40,7 +40,7 @@ import type { import { assetManager } from '../../assets'; import { LightmapAssetTransaction } from './lightfx/asset-transaction'; import { LightmapAssetRecord } from './lightfx/asset-record'; -import { isLightmapTextureUrl, publishLightmapTextures, removeEmptyLightmapVersion } from './lightfx/asset-publication'; +import { isLightmapTextureUrl, lightmapAuxiliaryPath, publishLightmapTextures, removeEmptyLightmapVersion } from './lightfx/asset-publication'; import { decodeLightFXOutput } from './lightfx/output'; import { LightFXProcess } from './lightfx/process'; @@ -66,6 +66,7 @@ interface LightFXHostOperation { assets: LightmapAssetTransaction | null; assetRecord?: LightmapAssetRecord; recordedTextureUuids?: string[]; + recordedAuxiliaryUuids?: string[]; publicationRootUrl: string; cleanupPromise: Promise | null; expiryTimer: NodeJS.Timeout | null; @@ -107,12 +108,12 @@ export class LightFXBakeHost implements ILightFXBakeHostService { private readonly diagnostics = new Map(); private sceneOperation: (IReserveLightFXSceneOperationOptions & ILightFXSceneOperationToken & { nativeStarted: boolean; nativeCommitted: boolean; removingAssets: boolean; - publication?: { operationId: string; textureUuids: string[]; stagingUrl: string; rootUrl: string }; + publication?: { operationId: string; textureUuids: string[]; auxiliaryUuids: string[]; stagingUrl: string; rootUrl: string }; }) | null = null; private readonly releasedSceneOperations = new Set(); public async queryCapabilities(): Promise { - return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; + return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapAuxiliaryAssetsVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; } public async queryDiagnostics(options: ICancelLightFXOperationOptions): Promise { @@ -453,7 +454,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { this.sceneOperation.nativeCommitted = true; if (operation.target === 'lightmap' && operation.recordedTextureUuids) { this.sceneOperation.publication = { operationId: operation.id, textureUuids: operation.recordedTextureUuids, - stagingUrl: operation.targetUrl, rootUrl: operation.publicationRootUrl }; + auxiliaryUuids: operation.recordedAuxiliaryUuids ?? [], stagingUrl: operation.targetUrl, rootUrl: operation.publicationRootUrl }; } } try { @@ -502,7 +503,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { if (this.operation || owner.removingAssets) throw new Error('Lightmap asset publication or cleanup is already in progress.'); owner.removingAssets = true; try { - return { textureUrls: await publishLightmapTextures(this.queryAssetRoot(), publication.textureUuids, publication.stagingUrl, publication.rootUrl) }; + return { textureUrls: await publishLightmapTextures(this.queryAssetRoot(), publication.textureUuids, publication.stagingUrl, publication.rootUrl, publication.auxiliaryUuids) }; } finally { owner.removingAssets = false; } @@ -559,15 +560,26 @@ export class LightFXBakeHost implements ILightFXBakeHostService { }))]; const record = new LightmapAssetRecord(dirname(this.queryAssetRoot()), sceneUuid); const recorded = new Set(await record.read()); - const infos = new Map(uuids.map(uuid => [uuid, assetManager.queryAssetInfo(uuid)])); + // Native files never occur in a Lightmap binding. Their complete membership is + // host-owned, including retries after Clear already removed all texture bindings. + const currentAuxiliary = new Set(action === 'bake' ? owner.publication?.auxiliaryUuids ?? [] : []); + const auxiliary = new Set((await record.readAuxiliary()).filter(uuid => !currentAuxiliary.has(uuid))); + const candidates = [...new Set([...uuids, ...auxiliary])]; + const infos = new Map(candidates.map(uuid => [uuid, assetManager.queryAssetInfo(uuid)])); const managed = (uuid: string): boolean => isImmutableLightmapTexture(infos.get(uuid)?.url) - || (recorded.has(uuid) && isLightmapTextureUrl(infos.get(uuid)?.url)); - const known = uuids.filter(managed); + || (recorded.has(uuid) && isLightmapTextureUrl(infos.get(uuid)?.url)) + || (auxiliary.has(uuid) && !!infos.get(uuid)?.url?.startsWith('db://assets/') + && !!lightmapAuxiliaryPath(infos.get(uuid)!.url!.split('/').at(-1)!)); + const known = uuids.filter(uuid => !auxiliary.has(uuid) && managed(uuid)); // Include legacy currently-bound candidates before deletion so a retained/failed // delete can be retried after the saved scene no longer has any Lightmap binding. if (known.length) await record.add(known); const result: IRemoveLightmapAssetsResult = { deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }; - for (const uuid of uuids) { + if (auxiliary.size) { + result.deletedAuxiliaryAssetUuids = []; + result.retainedAuxiliaryAssetUuids = []; + } + for (const uuid of candidates) { const info = infos.get(uuid); if (!info || !managed(uuid)) { result.failures.push({ uuid, reason: 'Asset is not a managed LightFX texture.' }); @@ -578,28 +590,32 @@ export class LightFXBakeHost implements ILightFXBakeHostService { const hasOtherUser = users.some((user) => { try { const userUuid = Utils.UUID.decompressUUID(user).split('@', 1)[0]; - return userUuid !== sceneUuid && userUuid !== uuid; + return userUuid !== uuid && (auxiliary.has(uuid) || userUuid !== sceneUuid); } catch { // An unknown dependency identifier is retained conservatively. return true; } }); if (hasOtherUser) { - result.retainedTextureUuids.push(uuid); + (auxiliary.has(uuid) ? result.retainedAuxiliaryAssetUuids! : result.retainedTextureUuids).push(uuid); continue; } await assetManager.removeAsset(uuid); if (info.file && await pathExists(info.file)) { throw new Error('Lightmap texture file still exists after asset deletion.'); } - result.deletedTextureUuids.push(uuid); + if (info.file && await pathExists(`${info.file}.meta`)) { + throw new Error('Lightmap asset metadata still exists after asset deletion.'); + } + (auxiliary.has(uuid) ? result.deletedAuxiliaryAssetUuids! : result.deletedTextureUuids).push(uuid); if (info.file) await removeEmptyLightmapVersion(this.queryAssetRoot(), dirname(info.url!)); } catch (error) { result.failures.push({ uuid, reason: error instanceof Error ? error.message : String(error) }); } } - if (result.deletedTextureUuids.length > 0) { - await record.forget(result.deletedTextureUuids); + const deleted = [...result.deletedTextureUuids, ...(result.deletedAuxiliaryAssetUuids ?? [])]; + if (deleted.length > 0) { + await record.forget(deleted); } return result; } finally { @@ -749,6 +765,17 @@ export class LightFXBakeHost implements ILightFXBakeHostService { await copy(join(operation.outputDir, file), join(operation.targetDir, file), { overwrite: true }); await assets.preserveMeta(file); } + const auxiliaryFiles: string[] = []; + if (operation.assetRecord && this.sceneOperation) { + // Copy before native commit cleans the workspace. Flat staging avoids a second + // directory transaction; publication maps these exact names to Creator's layout. + for (const file of ['lfx.in', 'lfx.out', 'lfx.log']) { + const source = join(operation.workspace, lightmapAuxiliaryPath(file)!); + if (file === 'lfx.log' && !(await pathExists(source))) continue; + await copy(source, join(operation.targetDir, file), { overwrite: false, errorOnExist: true }); + auxiliaryFiles.push(file); + } + } await assetManager.refreshAsset(operation.targetUrl); const generatedUuids: string[] = []; @@ -760,8 +787,13 @@ export class LightFXBakeHost implements ILightFXBakeHostService { generatedUuids.push(uuid); } if (operation.assetRecord) { - await operation.assetRecord.add(generatedUuids); + const auxiliaryUuids: string[] = []; + for (const file of auxiliaryFiles) { + auxiliaryUuids.push(await this.waitForAsset(operation, `${operation.targetUrl}/${file}`, Math.min(operation.timeoutMs, 60_000))); + } + await operation.assetRecord.add(generatedUuids, auxiliaryUuids); operation.recordedTextureUuids = generatedUuids; + operation.recordedAuxiliaryUuids = auxiliaryUuids; this.throwIfTerminated(operation); } return files.map((file) => `${operation.targetUrl}/${file}`); @@ -904,7 +936,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { await operation.assets.rollback(); await assetManager.refreshAsset(operation.refreshUrl); if (operation.recordedTextureUuids) { - await operation.assetRecord?.forget(operation.recordedTextureUuids); + await operation.assetRecord?.forget([...operation.recordedTextureUuids, ...(operation.recordedAuxiliaryUuids ?? [])]); } } let cleanupError: unknown; diff --git a/src/core/scene/main-process/lightfx/asset-publication.ts b/src/core/scene/main-process/lightfx/asset-publication.ts index 8f5d4d040..05a115937 100644 --- a/src/core/scene/main-process/lightfx/asset-publication.ts +++ b/src/core/scene/main-process/lightfx/asset-publication.ts @@ -8,6 +8,16 @@ export function isLightmapTextureUrl(url: string | undefined): boolean { return !!url?.startsWith('db://assets/') && /^LFX_(?:Mesh|Terrain)_\d{4,}\.png$/.test(url.split('/').at(-1) ?? ''); } +/** Only these native products belong to the Lightmap publication contract. */ +export function lightmapAuxiliaryPath(filename: string): string | undefined { + switch (filename) { + case 'lfx.in': return 'tmp/lfx.in'; + case 'lfx.out': return 'output/lfx.out'; + case 'lfx.log': return 'lfx.log'; + default: return undefined; + } +} + /** Reject symlinks (including dangling ones) before creating or moving managed outputs. */ async function assertAssetPath(assetRoot: string, path: string): Promise { const local = relative(assetRoot, path); @@ -44,20 +54,24 @@ export async function removeEmptyLightmapVersion(assetRoot: string, url: string) } /** Post-save relocation: UUIDs stay valid even if a later move fails. Never overwrite assets. */ -export async function publishLightmapTextures(assetRoot: string, uuids: readonly string[], stagingUrl: string, rootUrl: string): Promise { +export async function publishLightmapTextures(assetRoot: string, uuids: readonly string[], stagingUrl: string, rootUrl: string, + auxiliaryUuids: readonly string[] = []): Promise { if (!rootUrl.startsWith('db://assets') || (rootUrl !== 'db://assets' && !rootUrl.startsWith('db://assets/'))) { throw new Error('Invalid Lightmap publication directory.'); } const outputUrl = `${rootUrl}/output`; const outputDir = join(assetRoot, outputUrl.slice('db://assets/'.length)); await assertAssetPath(assetRoot, outputDir); - const files = uuids.map(uuid => { + const auxiliary = new Set(auxiliaryUuids); + const files = [...uuids, ...auxiliaryUuids].map(uuid => { const info = assetManager.queryAssetInfo(uuid); - if (!info?.file || !isLightmapTextureUrl(info.url)) throw new Error('Published Lightmap texture is missing.'); + if (!info?.file || !info.url?.startsWith('db://assets/')) throw new Error('Published Lightmap asset is missing.'); const filename = basename(info.file); - const url = `${outputUrl}/${filename}`; + const path = auxiliary.has(uuid) ? lightmapAuxiliaryPath(filename) : isLightmapTextureUrl(info.url) ? `output/${filename}` : undefined; + if (!path) throw new Error('Invalid Lightmap publication asset.'); + const url = `${rootUrl}/${path}`; if (info.url !== `${stagingUrl}/${filename}` && info.url !== url) throw new Error('Lightmap publication source no longer belongs to this bake.'); - return { uuid, source: info.url!, sourceFile: info.file, url, file: join(outputDir, filename) }; + return { uuid, source: info.url, sourceFile: info.file, url, file: join(assetRoot, url.slice('db://assets/'.length)) }; }); if (new Set(files.map(file => file.url)).size !== files.length) throw new Error('Duplicate Lightmap output names.'); // Preflight every target before the first move. Partial moves still keep their original UUIDs. @@ -72,6 +86,7 @@ export async function publishLightmapTextures(assetRoot: string, uuids: readonly } } await ensureDir(outputDir); + for (const file of files) await ensureDir(dirname(file.file)); await assetManager.refreshAsset(rootUrl); for (const file of files) { if (file.source !== file.url) await assetManager.moveAsset(file.source, file.url, { overwrite: false, rename: false }); @@ -82,5 +97,5 @@ export async function publishLightmapTextures(assetRoot: string, uuids: readonly } } await removeEmptyLightmapVersion(assetRoot, stagingUrl); - return files.map(file => file.url); + return files.filter(file => !auxiliary.has(file.uuid)).map(file => file.url); } diff --git a/src/core/scene/main-process/lightfx/asset-record.ts b/src/core/scene/main-process/lightfx/asset-record.ts index 2cdf034e6..26536f55f 100644 --- a/src/core/scene/main-process/lightfx/asset-record.ts +++ b/src/core/scene/main-process/lightfx/asset-record.ts @@ -14,42 +14,59 @@ export class LightmapAssetRecord { } async read(): Promise { + return (await this.readRecord()).textures; + } + + async readAuxiliary(): Promise { + return (await this.readRecord()).auxiliary ?? []; + } + + private async readRecord(): Promise<{ textures: string[]; auxiliary?: string[] }> { let text: string; try { if ((await stat(this.file)).size > 512 * 1024) throw new Error('Lightmap generated-asset record is too large.'); text = await readFile(this.file, 'utf8'); } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { textures: [] }; throw error; } - const record = JSON.parse(text) as { version?: number; textures?: unknown }; + const record = JSON.parse(text) as { version?: number; textures?: unknown; auxiliary?: unknown }; if (record?.version !== 1 || !Array.isArray(record.textures) || record.textures.length > 10_000 - || record.textures.some(uuid => typeof uuid !== 'string' || !Utils.UUID.isUUID(uuid))) { + || record.textures.some(uuid => typeof uuid !== 'string' || !Utils.UUID.isUUID(uuid)) + || (record.auxiliary !== undefined && (!Array.isArray(record.auxiliary) || record.auxiliary.length > 10_000 + || record.auxiliary.some(uuid => typeof uuid !== 'string' || !Utils.UUID.isUUID(uuid))))) { throw new Error('Invalid Lightmap generated-asset record.'); } - return [...new Set(record.textures as string[])]; + return { textures: [...new Set(record.textures as string[])], + ...(record.auxiliary !== undefined ? { auxiliary: [...new Set(record.auxiliary as string[])] } : {}) }; } - async add(uuids: readonly string[]): Promise { + async add(uuids: readonly string[], auxiliaryUuids: readonly string[] = []): Promise { const roots = uuids.map(uuid => Utils.UUID.decompressUUID(uuid).split('@', 1)[0]); - if (roots.some(uuid => !Utils.UUID.isUUID(uuid))) throw new Error('Invalid generated Lightmap texture UUID.'); - const textures = [...new Set([...(await this.read()), ...roots])]; - if (textures.length > 10_000) throw new Error('Too many recorded Lightmap assets; clear unused bake results first.'); - await this.write(textures); + const auxiliary = auxiliaryUuids.map(uuid => Utils.UUID.decompressUUID(uuid).split('@', 1)[0]); + if ([...roots, ...auxiliary].some(uuid => !Utils.UUID.isUUID(uuid))) throw new Error('Invalid generated Lightmap texture UUID.'); + const previous = await this.readRecord(); + const textures = [...new Set([...previous.textures, ...roots])]; + const combined = [...new Set([...(previous.auxiliary ?? []), ...auxiliary])]; + if (textures.length + combined.length > 10_000) throw new Error('Too many recorded Lightmap assets; clear unused bake results first.'); + await this.write({ textures, ...(combined.length || previous.auxiliary ? { auxiliary: combined } : {}) }); } async forget(uuids: readonly string[]): Promise { const deleted = new Set(uuids); - const previous = await this.read(); - const textures = previous.filter(uuid => !deleted.has(uuid)); - if (textures.length !== previous.length) await this.write(textures); + const previous = await this.readRecord(); + const textures = previous.textures.filter(uuid => !deleted.has(uuid)); + const auxiliary = previous.auxiliary?.filter(uuid => !deleted.has(uuid)); + if (textures.length !== previous.textures.length || auxiliary?.length !== previous.auxiliary?.length) { + await this.write({ textures, ...(auxiliary ? { auxiliary } : {}) }); + } } - private async write(textures: string[]): Promise { + private async write(record: { textures: string[]; auxiliary?: string[] }): Promise { await ensureDir(dirname(this.file)); const temporary = `${this.file}.${randomUUID()}.tmp`; try { - await outputFile(temporary, JSON.stringify({ version: 1, textures })); + await outputFile(temporary, JSON.stringify({ version: 1, ...record })); await rename(temporary, this.file); } finally { await remove(temporary); diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index 933519c01..de546af19 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -47,7 +47,8 @@ export class LightmapBakeService extends BaseService impleme const sceneUrl = await this.querySceneUrl(); // Preflight before native publication or scene mutation, not after a successful save. const capabilities = await lightFXBakeHost.queryCapabilities(); - if (capabilities?.lightmapRebakeCleanupVersion !== 1 || capabilities.lightmapPublicationVersion !== 1) { + if (capabilities?.lightmapRebakeCleanupVersion !== 1 || capabilities.lightmapPublicationVersion !== 1 + || capabilities.lightmapAuxiliaryAssetsVersion !== 1) { throw new Error('The LightFX host does not support current Lightmap publication and cleanup. Restart the Cocos host after updating the CLI; reloading only the window may keep the old host.'); } const owned = (await lightFXBakeHost.queryLightmapTextureInfo({ uuids: [], sceneUuid: scene.uuid })).ownedTextureUuids ?? []; @@ -154,12 +155,11 @@ export class LightmapBakeService extends BaseService impleme const retained = await this.queryRemainingSceneTextureUuids(previous); const deletable = previous.filter(uuid => !retained.has(uuid)); const finishDeletion = deletedLightmapAssets.begin(scene, deletable); - const result = deletable.length > 0 - ? await lightFXCoordinator.removeLightmapAssets(scene.uuid, deletable, 'bake') - : { deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }; + const result = await lightFXCoordinator.removeLightmapAssets(scene.uuid, deletable, 'bake'); finishDeletion(result.deletedTextureUuids); - if (retained.size || result.retainedTextureUuids.length || result.failures.length) { - throw new Error(`Previous Lightmap cleanup incomplete: ${retained.size + result.retainedTextureUuids.length} referenced assets retained, ${result.failures.length} deletions failed.`); + const retainedCount = retained.size + result.retainedTextureUuids.length + (result.retainedAuxiliaryAssetUuids?.length ?? 0); + if (retainedCount || result.failures.length) { + throw new Error(`Previous Lightmap cleanup incomplete: ${retainedCount} referenced assets retained, ${result.failures.length} deletions failed.`); } } catch (error) { throw new Error(`New Lightmap result is saved and retained; previous asset cleanup was not completed. ${this.errorMessage(error)}`); @@ -218,9 +218,11 @@ export class LightmapBakeService extends BaseService impleme if (options.deleteAssets === true && options.saveScene === false) { throw new Error('deleteAssets requires saveScene so the saved scene cannot retain deleted lightmap references.'); } - if (options.deleteAssets === true - && (await lightFXBakeHost.queryCapabilities())?.lightmapAssetCleanupVersion !== 1) { - throw new Error('The LightFX host does not support exact Lightmap asset cleanup.'); + if (options.deleteAssets === true) { + const capabilities = await lightFXBakeHost.queryCapabilities(); + if (capabilities?.lightmapAssetCleanupVersion !== 1 || capabilities.lightmapAuxiliaryAssetsVersion !== 1) { + throw new Error('The LightFX host does not support exact Lightmap asset cleanup. Restart the Cocos host after updating the CLI.'); + } } // Query before recording/clearing so a damaged ownership record cannot partially Clear. @@ -275,14 +277,12 @@ export class LightmapBakeService extends BaseService impleme Service.Undo.cancelRecording(undo); const deletableTextureUuids = textureUuids.filter(uuid => !retainedSceneTextureUuids.has(uuid)); const finishDeletion = deletedLightmapAssets.begin(scene, deletableTextureUuids); - const result = deletableTextureUuids.length > 0 - ? await lightFXCoordinator.removeLightmapAssets(scene.uuid, deletableTextureUuids) - : { deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }; + const result = await lightFXCoordinator.removeLightmapAssets(scene.uuid, deletableTextureUuids); finishDeletion(result.deletedTextureUuids); return { clearedCount: bindings.length, - deletedAssetCount: result.deletedTextureUuids.length, - retainedAssetCount: retainedSceneTextureUuids.size + result.retainedTextureUuids.length, + deletedAssetCount: result.deletedTextureUuids.length + (result.deletedAuxiliaryAssetUuids?.length ?? 0), + retainedAssetCount: retainedSceneTextureUuids.size + result.retainedTextureUuids.length + (result.retainedAuxiliaryAssetUuids?.length ?? 0), failedAssetCount: result.failures.length, }; } diff --git a/src/core/scene/test/lightfx-asset-publication.test.ts b/src/core/scene/test/lightfx-asset-publication.test.ts index 6573e18a0..969b47d51 100644 --- a/src/core/scene/test/lightfx-asset-publication.test.ts +++ b/src/core/scene/test/lightfx-asset-publication.test.ts @@ -83,6 +83,33 @@ describe('Fixed Lightmap publication after saving', () => { await expect(publishLightmapTextures(root, [uuid], staging, 'db://assets/LightFX')).rejects.toThrow('symbolic'); expect(mockAssets.moveAsset).not.toHaveBeenCalled(); }); + it('publishes native file bytes with their UUIDs but returns only preview texture URLs', async () => { + const texture = await add('LFX_Mesh_0000.png'); + const native = await Promise.all(['lfx.in', 'lfx.out', 'lfx.log'].map(add)); + const result = await publishLightmapTextures(root, [texture], staging, 'db://assets/Chosen', native); + expect(result).toEqual(['db://assets/Chosen/output/LFX_Mesh_0000.png']); + const urls = ['tmp/lfx.in', 'output/lfx.out', 'lfx.log'].map(file => `db://assets/Chosen/${file}`); + expect(native.map(uuid => infos.get(uuid)?.url)).toEqual(urls); + expect(await Promise.all(urls.map(url => readFile(path(url), 'utf8')))).toEqual(['pixels:lfx.in', 'pixels:lfx.out', 'pixels:lfx.log']); + expect(await pathExists(path(staging))).toBe(false); + }); + it('preflights native collisions before moving any PNG and never replaces unrelated files', async () => { + const texture = await add('LFX_Mesh_0000.png'), log = await add('lfx.log'); + const destination = path('db://assets/LightFX/lfx.log'); + await outputFile(destination, 'user log'); + await expect(publishLightmapTextures(root, [texture], staging, 'db://assets/LightFX', [log])).rejects.toThrow('occupied'); + expect(mockAssets.moveAsset).not.toHaveBeenCalled(); + expect(await readFile(destination, 'utf8')).toBe('user log'); + }); + it('retains new assets on a partial native move failure and can retry without duplicate PNG moves', async () => { + const texture = await add('LFX_Mesh_0000.png'), input = await add('lfx.in'); + const original = mockAssets.moveAsset.getMockImplementation()!; + mockAssets.moveAsset.mockImplementationOnce(original).mockRejectedValueOnce(new Error('native move denied')); + await expect(publishLightmapTextures(root, [texture], staging, 'db://assets/LightFX', [input])).rejects.toThrow('native move denied'); + expect([await pathExists(infos.get(texture)!.file), await pathExists(infos.get(input)!.file)]).toEqual([true, true]); + await publishLightmapTextures(root, [texture], staging, 'db://assets/LightFX', [input]); + expect(await pathExists(path(staging))).toBe(false); + }); it('never deletes a nonempty version folder or a regular output directory', async () => { await outputFile(path(`${staging}/unrelated.txt`), 'keep'); await removeEmptyLightmapVersion(root, staging); diff --git a/src/core/scene/test/lightfx-asset-record.test.ts b/src/core/scene/test/lightfx-asset-record.test.ts index aac839af6..6e298f123 100644 --- a/src/core/scene/test/lightfx-asset-record.test.ts +++ b/src/core/scene/test/lightfx-asset-record.test.ts @@ -25,11 +25,22 @@ describe('Exact scene Lightmap asset membership', () => { .toBe(JSON.stringify({ version: 1, textures: [b] })); }); + it('keeps auxiliary membership separate while preserving it through texture changes and restart', async () => { + const record = new LightmapAssetRecord(root, scene); + await record.add([a], [`${b}@sub`, b]); + await record.forget([a]); + await new LightmapAssetRecord(root, scene).add([a]); + expect([await record.read(), await record.readAuxiliary()]).toEqual([[a], [b]]); + await record.forget([b]); + expect([await record.read(), await record.readAuxiliary()]).toEqual([[a], []]); + }); + it.each(['../outside', '', 'scene/name'])('rejects unsafe scene identity: %s', invalid => { expect(() => new LightmapAssetRecord(root, invalid)).toThrow('scene UUID'); }); - it.each(['{broken', 'null', '{"version":2,"textures":[]}', '{"version":1,"textures":["../outside"]}'])('does not overwrite a damaged record: %s', async content => { + it.each(['{broken', 'null', '{"version":2,"textures":[]}', '{"version":1,"textures":["../outside"]}', + '{"version":1,"textures":[],"auxiliary":["../outside"]}', '{"version":1,"textures":[],"auxiliary":null}'])('does not overwrite a damaged record: %s', async content => { const file = join(root, 'settings', 'lightfx-assets', `${scene}.json`); await outputFile(file, content); const record = new LightmapAssetRecord(root, scene); diff --git a/src/core/scene/test/lightfx-asset-versions.test.ts b/src/core/scene/test/lightfx-asset-versions.test.ts index c73507a28..2a39c1a0c 100644 --- a/src/core/scene/test/lightfx-asset-versions.test.ts +++ b/src/core/scene/test/lightfx-asset-versions.test.ts @@ -13,6 +13,7 @@ jest.mock('../../assets', () => ({ assetManager: mockAssets })); jest.mock('../main-process/lightfx/process', () => ({ LightFXProcess: jest.fn(() => ({ run: mockRun, cancel: async () => undefined })) })); jest.mock('../main-process/lightfx/output', () => ({ decodeLightFXOutput: () => ({ version: 1, meshes: [], terrains: [], probes: [] }) })); import { LightFXBakeHost } from '../main-process/lightfx-bake-host'; +import { LightmapAssetRecord } from '../main-process/lightfx/asset-record'; describe('Immutable Lightmap asset versions', () => { let root: string; @@ -36,6 +37,7 @@ describe('Immutable Lightmap asset versions', () => { mockRun.mockImplementationOnce(async ({ cwd }: { cwd: string }) => { await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); await outputFile(join(cwd, 'output', 'LFX_Mesh_0000.png'), bytes); + await outputFile(join(cwd, 'lfx.log'), `native ${bytes}`); }); const token = await host.begin({ ...opts, outputUrl, sceneUuid, transactionId }); await host.appendInput({ ...token, chunkBase64: Buffer.from('input').toString('base64') }); @@ -87,12 +89,19 @@ describe('Immutable Lightmap asset versions', () => { const target = `${outputUrl && outputUrl !== 'db://assets' ? outputUrl : 'db://assets/LightFX'}/output/LFX_Mesh_0000.png`; expect(output.textureUrls).toEqual([target]); expect([identities.get(uuid)?.url, await readFile(assetPath(target), 'utf8'), await pathExists(a.path)]).toEqual([target, 'pixels A', false]); + const auxRoot = target.slice(0, -'/output/LFX_Mesh_0000.png'.length); + expect(await Promise.all(['tmp/lfx.in', 'output/lfx.out', 'lfx.log'].map(path => readFile(assetPath(`${auxRoot}/${path}`), 'utf8')))) + .toEqual(['input', '', 'native pixels A']); + const auxiliary = await new LightmapAssetRecord(root, sceneUuid).readAuxiliary(); + expect(auxiliary).toHaveLength(3); await expect(host.publishLightmapAssets(request)).resolves.toEqual(output); await host.releaseSceneOperation(owner); host = new LightFXBakeHost(); expect((await host.queryLightmapTextureInfo({ sceneUuid, uuids: [] })).ownedTextureUuids).toEqual([uuid]); const cleared = await host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid] }); expect([cleared.deletedTextureUuids, await pathExists(assetPath(target))]).toEqual([[uuid], false]); + expect(cleared.deletedAuxiliaryAssetUuids).toEqual(auxiliary); + expect(await new LightmapAssetRecord(root, sceneUuid).readAuxiliary()).toEqual([]); }); it('cleans prior files inside the committed Bake reservation without deleting the new result', async () => { @@ -111,7 +120,67 @@ describe('Immutable Lightmap asset versions', () => { expect([await pathExists(a.path), await readFile(b.path, 'utf8'), (await host.queryCapabilities()).busy]) .toEqual([false, 'pixels B', true]); await host.releaseSceneOperation(owner); - expect((await host.queryLightmapTextureInfo({ sceneUuid, uuids: [] })).ownedTextureUuids).toEqual([...identities.keys()]); + expect((await host.queryLightmapTextureInfo({ sceneUuid, uuids: [] })).ownedTextureUuids) + .toEqual([...identities.values()].filter(info => info.url.endsWith('.png')).map(info => info.uuid)); + }); + + it('replaces all native products after saving and protects the current set during rebake cleanup', async () => { + const identities = realAssetFiles(), sceneUuid = randomUUID(); + const first = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const a = await bake('A', undefined, sceneUuid, first.transactionId); + await host.commit(a.token); + await host.publishLightmapAssets({ ...a.token, ...first }); + const oldIds = [...identities.keys()]; + await host.releaseSceneOperation(first); + const second = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const b = await bake('B', undefined, sceneUuid, second.transactionId); + await host.commit(b.token); + const cleaned = await host.removeLightmapAssets({ ...second, sceneUuid, action: 'bake', textureUuids: [oldIds[0]] }); + expect([cleaned.deletedTextureUuids, cleaned.deletedAuxiliaryAssetUuids, cleaned.failures]) + .toEqual([[oldIds[0]], oldIds.slice(1), []]); + expect(await pathExists(b.path)).toBe(true); + await host.publishLightmapAssets({ ...b.token, ...second }); + expect(await readFile(assetPath('db://assets/LightFX/lfx.log'), 'utf8')).toBe('native B'); + expect([...identities.values()].map(info => info.url)).toEqual([ + 'db://assets/LightFX/output/LFX_Mesh_0000.png', 'db://assets/LightFX/tmp/lfx.in', + 'db://assets/LightFX/output/lfx.out', 'db://assets/LightFX/lfx.log', + ]); + await host.releaseSceneOperation(second); + }); + + it('protects native products referenced by the same scene and retries with no texture candidates after restart', async () => { + const identities = realAssetFiles(), sceneUuid = randomUUID(); + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const a = await bake('A', undefined, sceneUuid, owner.transactionId); + await host.commit(a.token); + await host.publishLightmapAssets({ ...a.token, ...owner }); + await host.releaseSceneOperation(owner); + const [png, ...auxiliary] = [...identities.keys()]; + mockAssets.queryAssetUsers.mockImplementation(async uuid => auxiliary.includes(uuid) ? [sceneUuid] : []); + const result = await host.removeLightmapAssets({ sceneUuid, textureUuids: [png] }); + expect([result.deletedTextureUuids, result.retainedAuxiliaryAssetUuids]).toEqual([[png], auxiliary]); + host = new LightFXBakeHost(); + mockAssets.queryAssetUsers.mockResolvedValue([]); + expect((await host.removeLightmapAssets({ sceneUuid, textureUuids: [] })).deletedAuxiliaryAssetUuids).toEqual(auxiliary); + expect(identities.size).toBe(0); + }); + + it('rolls back only newly staged native products without altering a previous fixed result', async () => { + const identities = realAssetFiles(), sceneUuid = randomUUID(); + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const a = await bake('A', undefined, sceneUuid, owner.transactionId); + await host.commit(a.token); + await host.publishLightmapAssets({ ...a.token, ...owner }); + await host.releaseSceneOperation(owner); + const oldAuxiliary = await new LightmapAssetRecord(root, sceneUuid).readAuxiliary(); + const next = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const b = await bake('B', undefined, sceneUuid, next.transactionId); + await host.rollback(b.token); + expect(await new LightmapAssetRecord(root, sceneUuid).readAuxiliary()).toEqual(oldAuxiliary); + expect(await readFile(assetPath('db://assets/LightFX/lfx.log'), 'utf8')).toBe('native A'); + expect(await pathExists(b.path)).toBe(false); + await host.releaseSceneOperation(next); + expect([...identities.values()].filter(info => existsSync(info.file))).toHaveLength(4); }); it('does not accept rebake cleanup after a rolled-back native operation or without a reservation', async () => { @@ -274,6 +343,6 @@ describe('Immutable Lightmap asset versions', () => { mockAssets.refreshAsset.mockRejectedValueOnce(new Error('import unavailable')); await expect(bake('pixels B')).rejects.toThrow('import unavailable'); expect(await readFile(a.path, 'utf8')).toBe('pixels A'); - await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }); + await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapAuxiliaryAssetsVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }); }); }); diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts index 9cd7ba3cb..749d935b7 100644 --- a/src/core/scene/test/lightfx-bake-host.test.ts +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -90,7 +90,7 @@ describe('LightFXBakeHost', () => { }); it('queries protocol and occupancy without reserving, releasing or exposing ownership', async () => { - const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }; + const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapAuxiliaryAssetsVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }; const busy = { ...idle, busy: true }; await expect(host.queryCapabilities()).resolves.toEqual(idle); const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); diff --git a/src/core/scene/test/lightfx-result-failures.test.ts b/src/core/scene/test/lightfx-result-failures.test.ts index 5e10c9df9..5e9426c75 100644 --- a/src/core/scene/test/lightfx-result-failures.test.ts +++ b/src/core/scene/test/lightfx-result-failures.test.ts @@ -24,7 +24,7 @@ jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoord } })); jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { reserveSceneOperation: async () => ({ transactionId: 'owner' }), releaseSceneOperation: async () => undefined, - queryCapabilities: async () => ({ lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1 }), + queryCapabilities: async () => ({ lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapAuxiliaryAssetsVersion: 1 }), queryLightmapTextureInfo: async () => ({ textures: [], missingTextureUuids: [], ownedTextureUuids: [] }), } })); jest.mock('../scene-process/service/baking/lightfx/settings', () => ({ createDefaultLightFXSettings: () => ({}) })); diff --git a/src/core/scene/test/lightmap-result-recording.test.ts b/src/core/scene/test/lightmap-result-recording.test.ts index 1d8c823de..9ecff0305 100644 --- a/src/core/scene/test/lightmap-result-recording.test.ts +++ b/src/core/scene/test/lightmap-result-recording.test.ts @@ -6,7 +6,7 @@ const mockCommit = jest.fn(); const mockRollback = jest.fn(); const mockRemoveLightmapAssets = jest.fn(); const mockPublishLightmapAssets = jest.fn(); -const mockQueryCapabilities = jest.fn(async (): Promise<{ lightmapAssetCleanupVersion?: 1; lightmapRebakeCleanupVersion?: 1; lightmapPublicationVersion?: 1 }> => ({ lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1 })); +const mockQueryCapabilities = jest.fn(async (): Promise<{ lightmapAssetCleanupVersion?: 1; lightmapRebakeCleanupVersion?: 1; lightmapPublicationVersion?: 1; lightmapAuxiliaryAssetsVersion?: 1 }> => ({ lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapAuxiliaryAssetsVersion: 1 })); const mockUndo = { beginRecording: jest.fn(() => 'recording'), endRecording: jest.fn(async () => undefined), @@ -61,7 +61,7 @@ describe('Lightmap result recording targets', () => { beforeEach(() => { jest.clearAllMocks(); mockBake.mockReset(); - mockQueryCapabilities.mockResolvedValue({ lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1 }); + mockQueryCapabilities.mockResolvedValue({ lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapAuxiliaryAssetsVersion: 1 }); mockPublishLightmapAssets.mockReset().mockResolvedValue({ textureUrls: ['db://assets/LightFX/output/LFX_Mesh_0000.png'] }); mockQuerySceneSerializedData.mockResolvedValue('[]'); mockQueryTextureInfo.mockReset().mockResolvedValue({ textures: [], missingTextureUuids: [] }); @@ -125,7 +125,7 @@ describe('Lightmap result recording targets', () => { const f = fixture(); mockQuerySceneSerializedData.mockResolvedValueOnce(JSON.stringify({ custom: { __uuid__: 'old-texture@f9941' } })); await expect(f.service.bake()).rejects.toThrow('New Lightmap result is saved and retained'); - expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).toHaveBeenCalledWith('scene', [], 'bake'); expect(f.model._updateLightmap).toHaveBeenLastCalledWith(f.texture, 0.1, 0.2, 0.3, 0.4); expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); expect(mockRollback).not.toHaveBeenCalled(); @@ -191,7 +191,7 @@ describe('Lightmap result recording targets', () => { }); expect(mockSave).toHaveBeenCalledTimes(1); expect(mockUndo.clearHistory).not.toHaveBeenCalled(); - expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).toHaveBeenCalledWith('scene', []); }); it('restores bindings without saving when the live scene reference check fails', async () => { const f = fixture(); @@ -262,7 +262,25 @@ describe('Lightmap result recording targets', () => { f.terrain._lightmapInfos = []; await f.service.clearBake({ deleteAssets: true }); expect(mockUndo.clearHistory).not.toHaveBeenCalled(); - expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).toHaveBeenCalledWith('scene', []); + }); + it('counts auxiliary deletion and retention even after texture bindings were already cleared', async () => { + const f = fixture(); + f.model.bakeSettings.texture = null as any; + f.terrain._lightmapInfos = []; + mockRemoveLightmapAssets.mockResolvedValueOnce({ deletedTextureUuids: [], retainedTextureUuids: [], + deletedAuxiliaryAssetUuids: ['input', 'log'], retainedAuxiliaryAssetUuids: ['output'], failures: [] }); + await expect(f.service.clearBake({ deleteAssets: true })).resolves.toEqual({ + clearedCount: 0, deletedAssetCount: 2, retainedAssetCount: 1, failedAssetCount: 0, + }); + expect(mockSave.mock.invocationCallOrder[0]).toBeLessThan(mockRemoveLightmapAssets.mock.invocationCallOrder[0]); + }); + it('rejects a host lacking auxiliary cleanup before changing bindings or saving', async () => { + const f = fixture(); + mockQueryCapabilities.mockResolvedValueOnce({ lightmapAssetCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapRebakeCleanupVersion: 1 }); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('Restart the Cocos host'); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalled(); }); it('replaces excluded objects old bindings, recording them for normal rebake Undo', async () => { const f = fixture(); From 16f4d4f0e17e79e5f29fb2e0e34aa212a6dba552 Mon Sep 17 00:00:00 2001 From: zenos Date: Fri, 11 Sep 2026 23:01:24 +0800 Subject: [PATCH 54/64] =?UTF-8?q?docs/=20=E8=AE=B0=E5=BD=95=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E7=83=98=E7=84=99=E9=85=8D=E5=A5=97=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E4=B8=8E=E5=AE=9E=E6=9C=BA=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 181e3f9ac..1774dc795 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -344,10 +344,22 @@ Scene 侧 `LightProbeBake.cancel()`/`LightmapBake.cancel()` 只取消本 rende 本批补齐成功重烘焙的收尾链路:修改场景前校验 Host 的内部 `lightmapRebakeCleanupVersion === 1` 并读取旧候选;新结果应用、录制和保存确认后,使旧 Lightmap 历史失效(保留本次新结果的 Redo 和普通历史),检查实时剩余引用并清理旧候选。Host 只接受仍持有正确 Bake reservation 且原生已 commit 的 `action:bake` 清理。当前新结果、其他字段/场景/材质引用必须保留。删除失败、引用保留或回应不明时返回包含 `New Lightmap result is saved and retained` 的错误,保留已完成的新结果并明确报告,不再恢复旧内存冒充回滚;不能把它解释成 Bake 没有修改场景。 -`saveScene:false` 不授权删除磁盘已保存场景仍依赖的贴图,不隐式保存,也不删除旧产物或固定发布;成功后的旧结果历史仍失效。保存失败/取消/应用失败不触发旧产物清理。下一次成功保存的 Bake 或删除模式 Clear 可重试已记录产物。`31598b0a` 已接入保存后的固定 PNG 发布;`tmp/lfx.in`/`output/lfx.out`/`lfx.log` 固定发布仍待接入,不宣称完整产物已经对齐。 +`saveScene:false` 不授权删除磁盘已保存场景仍依赖的贴图,不隐式保存,也不删除旧产物或固定发布;成功后的旧结果历史仍失效。保存失败/取消/应用失败不触发旧产物清理。下一次成功保存的 Bake 或删除模式 Clear 可重试已记录产物。`31598b0a` 已接入保存后的固定 PNG 发布;`2b13cfce` 已接入原生配套文件发布与清理,真实面板正常主链路已通过,异常与同场景 Creator 对照仍待专项验收。 ## Lightmap 资产规则 +### 原生配套文件接入(实施前记录) + +下一批复用 PNG 的暂存/保存后发布事务:原生完成时将实际 `tmp/lfx.in`、`output/lfx.out` 和存在的 `lfx.log` 导入本轮暂存目录,记录其根 UUID 为独立 auxiliary 成员,不混入贴图预览或纹理数量。保存后发布为 `<根>/tmp/lfx.in`、`<根>/output/lfx.out`、`<根>/lfx.log`。这比延长原生工作目录生命周期更小,commit 仍可按原机制清理工作目录。日志缺失不伪造文件,输入/输出缺失仍按实际失败处理。 + +成功重烘焙和删除式 Clear 清理已记录的旧配套资产;Bake 排除本次 UUID。配套文件不是 Lightmap 绑定,因此不得忽略当前场景对它的其他引用。删除/移动均核对磁盘、UUID 和元数据,固定目标冲突不覆盖。显式不保存和保存失败不提前删旧产物。复用现有归属文件的可选 auxiliary 字段,读写保留旧格式兼容,损坏字段在修改场景前报错;不扫描项目、不按目录猜归属、不留成功历史副本。新增内部能力位约束新旧 Host 混用;公共 MCP 不扩展新入口。 + +实现 `2b13cfce`:上述配套文件以独立 auxiliary UUID 记录,与 PNG 同批预检并移动;返回的 textureUrls 只含 PNG。Scene 在无纹理候选时仍执行 Host 清理以支持辅助资产失败重试,Clear 的资产计数包括辅助文件,绑定计数不变。内部能力位为 `lightmapAuxiliaryAssetsVersion:1`。原生输入文件可能引用临时纹理源,此批未承诺把全部纹理源附件发布成可脱离项目重放的输入包;不扩展为原生工程归档器。 + +先 `tsc -b`/Scene 与 editor-extends 构建,再定点 6 套/144 项、扩展 32 套/535 项通过(`/tmp/pink-native-products-final-tests.log`);定点 ESLint 无代码错误,保留已有配置告警。覆盖实际文件移动、固定冲突前置拒绝、部分移动失败、辅助字段损坏、回滚保留上轮、同场景其他引用保留和无纹理候选重试。 + +隔离工程 `/tmp/pink-native-products.SjK8Ju` 由主 agent 准备新 Host 后交全局 ui_verifier 执行真实按钮,证据 `/tmp/codex-ui-verifier.10oRvd`:128 Bake 产生固定 3 PNG+3 配套文件,256 Bake 替换为 2 PNG+3 新 UUID 配套文件;无缺图,保存成功。Clear 后这 5 个当前资产及 meta 实际不存在,归属 textures/auxiliary 为空,绑定及 UV 清空,43 点 SH 哈希不变,一次 Undo/Redo 未恢复。主 agent 已复核磁盘/JSON/截图。空目录及目录 meta 保留;旧版无归属历史不扫描删除。未直接查询 Asset DB 旧 UUID 缓存,也未在本批重复关闭重开或注入异常;不把正常主链路扩展为所有边界已验收。既有告警及点击 Clear 时瞬时 Console 计数差异均保留原始证据。 + 固定贴图发布的最小接入(实施前记录):继续使用独立临时导入目录完成纹理加载和场景保存;保存确认、旧产物清理完成后,通过 Asset DB 保留 UUID 移动到默认 `db://assets/LightFX/output`,指定 `outputUrl` 时移动到 `/output`。固定发布由原 Bake reservation 和实际 operation ID 校验,不接受任意外部 UUID。所有目标先检查冲突,逐项移动后核对 UUID、URL 和磁盘源/目标;不覆盖同名资产、不先复用旧 UUID。失败保留已保存的新结果位置,不删除新贴图。只用非递归空目录删除收敛已清空的 `bake-UUID`,其他文件存在时保留。`saveScene:false` 暂不固定发布,保护磁盘旧引用。本批不宣称 `lfx.in/out/log` 配套文件已经对齐。 Lightmap 先按每次烘焙的 operation UUID 导入到独立暂存目录(以下为省略 `outputUrl` 时的模板),这不是成功后保留的历史版本: From a3a4a1df85ccf86bb88bd90159bb3b12888ff21c Mon Sep 17 00:00:00 2001 From: zenos Date: Sat, 12 Sep 2026 09:58:20 +0800 Subject: [PATCH 55/64] =?UTF-8?q?fix/=20=E4=BF=AE=E5=A4=8D=E5=85=89?= =?UTF-8?q?=E7=85=A7=E8=B4=B4=E5=9B=BE=20GI=20=E9=87=87=E6=A0=B7=E6=BA=A2?= =?UTF-8?q?=E5=87=BA=E5=AF=BC=E8=87=B4=E5=8E=9F=E7=94=9F=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/scene/lightfx-bake-schema.ts | 4 +++- src/core/scene/common/lightfx-limits.ts | 10 ++++++++++ .../service/baking/lightfx/format.ts | 2 ++ .../scene-process/service/lightmap-bake.ts | 3 +++ src/core/scene/test/lightfx-format.test.ts | 20 +++++++++++++++++++ .../test/lightmap-result-recording.test.ts | 10 ++++++++++ tests/lightfx-bake-api.test.ts | 7 +++++++ 7 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 src/core/scene/common/lightfx-limits.ts diff --git a/src/api/scene/lightfx-bake-schema.ts b/src/api/scene/lightfx-bake-schema.ts index 7d7ea5c9d..6e57bccf0 100644 --- a/src/api/scene/lightfx-bake-schema.ts +++ b/src/api/scene/lightfx-bake-schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { MAX_LIGHTMAP_GI_SAMPLES } from '../../core/scene/common/lightfx-limits'; const SaveAndTimeout = { saveScene: z.boolean().optional().describe('Save the current scene after applying the bake result; defaults to true'), @@ -29,7 +30,8 @@ export const SchemaLightmapBakeOptions = z.object({ resolution: z.union([z.literal(128), z.literal(256), z.literal(512), z.literal(1024), z.literal(2048)]).optional(), filter: z.boolean().optional(), highp: z.boolean().optional(), giScale: z.number().finite().min(0).max(100).optional(), - giSamples: z.number().int().min(1).max(65535).optional(), + giSamples: z.number().int().min(1).max(MAX_LIGHTMAP_GI_SAMPLES).optional() + .describe('Lightmap GI sampling factor; 1–2590. Large values have quadratic memory and time costs.'), giPathLength: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional(), aoLevel: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(), aoStrength: z.number().finite().min(0).optional(), diff --git a/src/core/scene/common/lightfx-limits.ts b/src/core/scene/common/lightfx-limits.ts new file mode 100644 index 000000000..282b4ee36 --- /dev/null +++ b/src/core/scene/common/lightfx-limits.ts @@ -0,0 +1,10 @@ +// LightFX allocates giSamples² × 64 × 5 Vec2 entries using signed 32-bit arithmetic. +// This is an overflow boundary, not a memory/performance recommendation. +export const MAX_LIGHTMAP_GI_SAMPLES = Math.floor(Math.sqrt(0x7fffffff / (64 * 5))); + +/** Reject unsafe Lightmap input without silently changing the requested quality. */ +export function validateLightmapGISamples(value: number): void { + if (!Number.isInteger(value) || value < 1 || value > MAX_LIGHTMAP_GI_SAMPLES) { + throw new RangeError(`Lightmap GI Samples must be an integer between 1 and ${MAX_LIGHTMAP_GI_SAMPLES}. Higher values overflow the native LightFX sample buffer. Reduce GI Samples and bake again.`); + } +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/format.ts b/src/core/scene/scene-process/service/baking/lightfx/format.ts index 88faf981b..0f93e1bff 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/format.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/format.ts @@ -1,7 +1,9 @@ import { LightFXBuffer } from './buffer'; import { LIGHTFX_FILE_VERSION, LightFXChunk, LightFXWorld } from './types'; +import { validateLightmapGISamples } from '../../../../common/lightfx-limits'; export function encodeLightFXInput(world: LightFXWorld): Uint8Array { + if (world.settings.bakeLightmap) validateLightmapGISamples(world.settings.giSamples); const b = new LightFXBuffer(); const s = world.settings; b.writeInt32(LIGHTFX_FILE_VERSION); b.writeString(world.name); b.writeFloats([0, 0, 0]); b.writeFloats(s.skyRadiance); b.writeInt32(s.msaa); b.writeInt32(s.size); b.writeFloat(s.gamma); b.writeInt8(s.highp ? 1 : 0); diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index de546af19..f92d86d88 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -13,6 +13,7 @@ import { finishSavedLightFXRecording, LightFXResultRetainedError } from './bakin import { deletedLightmapAssets } from './baking/lightfx/deleted-lightmap-assets'; import { BaseService, register, Service } from './core'; import { loadPreviewAsset } from './preview/asset-reload'; +import { validateLightmapGISamples } from '../../common/lightfx-limits'; interface LightmapBinding { target: any; @@ -36,6 +37,8 @@ export class LightmapBakeService extends BaseService impleme } async bake(options: ILightmapBakeOptions = {}): Promise { + // Scene callers (including PinK) do not necessarily pass through the public API schema. + if (options.giSamples !== undefined) validateLightmapGISamples(options.giSamples); return lightFXSceneOperation.run('lightmap', 'bake', () => this.bakeExclusive(options)); } diff --git a/src/core/scene/test/lightfx-format.test.ts b/src/core/scene/test/lightfx-format.test.ts index 1e11e476c..ed76838ea 100644 --- a/src/core/scene/test/lightfx-format.test.ts +++ b/src/core/scene/test/lightfx-format.test.ts @@ -3,8 +3,28 @@ import { encodeLightFXInput } from '../scene-process/service/baking/lightfx/form import { LIGHTFX_FILE_VERSION, LightFXChunk, LightFXWorld } from '../scene-process/service/baking/lightfx/types'; import { createDefaultLightFXSettings } from '../scene-process/service/baking/lightfx/settings'; import { decodeLightFXOutput } from '../main-process/lightfx/output'; +import { MAX_LIGHTMAP_GI_SAMPLES } from '../common/lightfx-limits'; describe('LightFX binary format', () => { + it('uses the last non-overflowing native Lightmap sampling factor', () => { + expect(MAX_LIGHTMAP_GI_SAMPLES).toBe(2590); + expect(MAX_LIGHTMAP_GI_SAMPLES ** 2 * 64 * 5).toBeLessThanOrEqual(0x7fffffff); + expect((MAX_LIGHTMAP_GI_SAMPLES + 1) ** 2 * 64 * 5).toBeGreaterThan(0x7fffffff); + }); + + it.each([1, 25, 1024, 2590])('encodes valid Lightmap sampling factor %s unchanged', giSamples => { + const world: LightFXWorld = { name: 'Scene', settings: { ...createDefaultLightFXSettings('lightmap'), giSamples }, textures: [], terrains: [], meshes: [], lights: [], probes: [] }; + const encoded = encodeLightFXInput(world); + // Version + length-prefixed name + origin/sky + msaa/size/gamma/highp + giScale. + const offset = 4 + 4 + 'Scene'.length + 24 + 12 + 1 + 4; + expect(new DataView(encoded.buffer, encoded.byteOffset).getInt32(offset, true)).toBe(giSamples); + }); + + it.each([2591, 65535, 65536, 0, -1, 25.5, NaN, Infinity])('rejects unsafe Lightmap sampling factor %s before encoding', giSamples => { + const world: LightFXWorld = { name: 'Scene', settings: { ...createDefaultLightFXSettings('lightmap'), giSamples }, textures: [], terrains: [], meshes: [], lights: [], probes: [] }; + expect(() => encodeLightFXInput(world)).toThrow('Lightmap GI Samples must be an integer between 1 and 2590'); + }); + it('encodes both bake target flags and scene chunks', () => { const world: LightFXWorld = { name: 'Scene', settings: createDefaultLightFXSettings('light-probe'), textures: [], terrains: [], meshes: [], lights: [], probes: [{ position: [1, 2, 3], normal: [0, 1, 0] }] }; const encoded = encodeLightFXInput(world); diff --git a/src/core/scene/test/lightmap-result-recording.test.ts b/src/core/scene/test/lightmap-result-recording.test.ts index 9ecff0305..d6fe079d5 100644 --- a/src/core/scene/test/lightmap-result-recording.test.ts +++ b/src/core/scene/test/lightmap-result-recording.test.ts @@ -67,6 +67,16 @@ describe('Lightmap result recording targets', () => { mockQueryTextureInfo.mockReset().mockResolvedValue({ textures: [], missingTextureUuids: [] }); mockRemoveLightmapAssets.mockResolvedValue({ deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }); }); + it.each([2591, 65535, 0, -1, 25.5, NaN, Infinity])('rejects unsafe GI samples %s before native work or result mutation', async giSamples => { + const f = fixture(); + await expect(f.service.bake({ giSamples })).rejects.toThrow('Lightmap GI Samples must be an integer between 1 and 2590'); + expect([mockQueryCapabilities, mockBake, mockSave, mockCommit, mockRemoveLightmapAssets, mockUndo.beginRecording, + f.model._updateLightmap, f.terrain._updateLightmap].map(mock => mock.mock.calls.length)).toEqual(Array(8).fill(0)); + expect(f.model.bakeSettings.texture).toBe(f.oldTexture); + // Rejection must not leave an operation owner or poison the next bake. + await f.service.bake({ giSamples: 25 }); + expect(mockBake).toHaveBeenCalledTimes(1); + }); it.each([false, true])('records Mesh and Terrain components before scene flags for Bake (save=%s)', async saveScene => { const f = fixture(); await f.service.bake({ saveScene }); diff --git a/tests/lightfx-bake-api.test.ts b/tests/lightfx-bake-api.test.ts index d8b719200..91bdacb82 100644 --- a/tests/lightfx-bake-api.test.ts +++ b/tests/lightfx-bake-api.test.ts @@ -45,6 +45,13 @@ describe('LightFX bake API', () => { expect(() => SchemaLightmapBakeOptions.parse({ giPathLength: 5 })).toThrow(); expect(() => SchemaLightmapBakeOptions.parse({ aoLevel: 3 })).toThrow(); }); + it('rejects overflowing Lightmap GI samples without restricting probe samples', () => { + expect(SchemaLightmapBakeOptions.parse({ giSamples: 2590 })).toEqual({ giSamples: 2590 }); + for (const giSamples of [2591, 65535, 65536, 0, -1, 25.5, NaN, Infinity]) { + expect(SchemaLightmapBakeOptions.safeParse({ giSamples }).success).toBe(false); + } + expect(SchemaLightProbeBakeOptions.parse({ giSamples: 65535 })).toEqual({ giSamples: 65535 }); + }); it('forwards probe bake and wraps success', async () => { const data = { sceneUrl: 'db://assets/a.scene', probeCount: 4, giScale: 1, giSamples: 64, bounces: 1, reduceRinging: 0, showWireframe: true, showConvex: false, lightProbeSphereVolume: 1, durationMs: 10 }; probeBake.mockResolvedValue(data); await expect(new LightFXBakeApi().bakeLightProbes({ saveScene: true })).resolves.toEqual({ code: COMMON_STATUS.SUCCESS, data }); expect(probeBake).toHaveBeenCalledWith({ saveScene: true }); }); it('wraps LightFX failure', async () => { lightmapBake.mockRejectedValue(new Error('LightFX failed')); await expect(new LightFXBakeApi().bakeLightmap({})).resolves.toEqual({ code: COMMON_STATUS.FAIL, reason: 'LightFX failed' }); }); it('queries the current lightmap bake information', async () => { From 299def753ecca092043834fda7c8b347b7873005 Mon Sep 17 00:00:00 2001 From: zenos Date: Sat, 12 Sep 2026 09:59:02 +0800 Subject: [PATCH 56/64] =?UTF-8?q?docs/=20=E8=AE=B0=E5=BD=95=20GI=20?= =?UTF-8?q?=E9=87=87=E6=A0=B7=E6=BA=A2=E5=87=BA=E8=BE=B9=E7=95=8C=E5=8F=8A?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=E5=AE=9E=E6=9C=BA=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 1774dc795..20a71bde5 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -407,6 +407,12 @@ LightFX 当前可能输出 Creator 历史协议版本。解析器只接受已知 ## 错误与事务 +### Lightmap GI Samples 整数溢出防护(2026-09-12) + +已在隔离 PinK 实测 `giSamples=65535` 触发原生 `GenerateIntegrationSamples` 的 vector length_error/SIGABRT。随包 LightFX 对 Lightmap 采样数组长度以有符号32位计算 `giSamples² × 64 × 5`,故最大不溢出整数为2590(不是推荐值,也不保证大值在所有设备上的内存和耗时)。修复仅在公开参数schema、Scene直接入口及输入编码处前置拒绝非法值,不启动原生计算、不修改上一份结果、不静默clamp;Light Probe采样参数保持原契约。用户已决定暂不处理日志对齐,引用保护的隔离失败与用户手测不一致另行保留,不混入本次修复。 + +产品提交 `21e3b27d`。先通过 `tsc -b` 与 Scene bundle 构建,再通过32套件/556项回归测试及定向 ESLint。全局 ui_verifier 在新夹具 `/tmp/pink-gi-overflow.BNB4DT` 实际执行 GI25 生成 → GI65535 明确拒绝 → 改回25生成成功;拒绝前后场景、PNG、meta 的 hash 及绑定/UV不变,全量43点SH hash一致,两次正常生成均 `dirty:false`、无缺图。Host仅有两次正常任务的 begin/run,65535没有启动原生任务。原始截图、运行时及文件证据 `/tmp/codex-ui-verifier.Uszw6E`。未实机运行2590,不将算术上限当作性能验收;既有告警仍存在。 + 常见错误包括: - 当前没有打开已保存场景。 From b21aa0f342d6b13f210d6a026de61bb7ad0e6ab1 Mon Sep 17 00:00:00 2001 From: zenos Date: Sat, 12 Sep 2026 10:16:03 +0800 Subject: [PATCH 57/64] =?UTF-8?q?fix/=20=E6=B8=85=E7=90=86=E5=85=89?= =?UTF-8?q?=E7=85=A7=E8=B4=B4=E5=9B=BE=E5=89=8D=E6=A3=80=E6=9F=A5=E5=AD=90?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=9A=84=E5=A4=96=E9=83=A8=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scene/main-process/lightfx-bake-host.ts | 19 +++++++++- src/core/scene/test/lightfx-bake-host.test.ts | 35 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index d619adfa5..6d55eceb8 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -38,6 +38,7 @@ import type { IPublishLightmapAssetsOptions, } from '../common/lightfx-host'; import { assetManager } from '../../assets'; +import type { IAssetInfo } from '../../assets/@types/public'; import { LightmapAssetTransaction } from './lightfx/asset-transaction'; import { LightmapAssetRecord } from './lightfx/asset-record'; import { isLightmapTextureUrl, lightmapAuxiliaryPath, publishLightmapTextures, removeEmptyLightmapVersion } from './lightfx/asset-publication'; @@ -586,7 +587,23 @@ export class LightFXBakeHost implements ILightFXBakeHostService { continue; } try { - const users = await assetManager.queryAssetUsers(uuid); + // Dependency records name Texture/SpriteFrame subassets exactly. A parent + // image with no direct users can still own a texture used by another scene. + const identities = new Set([uuid]); + const collectSubassets = (asset: IAssetInfo): void => { + for (const child of Object.values(asset.subAssets ?? {})) { + const childUuid = Utils.UUID.decompressUUID(child.uuid); + if (childUuid.split('@', 1)[0] !== uuid || !Utils.UUID.isUUID(childUuid)) { + throw new Error('Invalid Lightmap subasset identity; asset retained.'); + } + if (identities.has(childUuid)) continue; + identities.add(childUuid); + collectSubassets(child); + } + }; + collectSubassets(info); + const users: string[] = []; + for (const identity of identities) users.push(...await assetManager.queryAssetUsers(identity)); const hasOtherUser = users.some((user) => { try { const userUuid = Utils.UUID.decompressUUID(user).split('@', 1)[0]; diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts index 749d935b7..81986077a 100644 --- a/src/core/scene/test/lightfx-bake-host.test.ts +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -251,6 +251,41 @@ describe('LightFXBakeHost', () => { expect(mockAssetManager.removeAsset).toHaveBeenCalledTimes(2); }); + it.each(['6c48a', 'f9941'])('retains a parent image referenced only through subasset %s', async suffix => { + const uuid = '11111111-1111-4111-8111-111111111111'; + const child = `${uuid}@${suffix}`; + mockAssetManager.queryAssetInfo.mockReturnValue({ uuid, + url: 'db://assets/Maps/bake-22222222-2222-4222-8222-222222222222/LFX_Mesh_0000.png', + subAssets: { [suffix]: { uuid: child, subAssets: {} } }, + }); + mockAssetManager.queryAssetUsers.mockImplementation(async (id: string) => id === child + ? ['66666666-6666-4666-8666-666666666666'] : []); + const options = { sceneUuid, textureUuids: [uuid] }; + await expect(host.removeLightmapAssets(options)).resolves.toEqual({ deletedTextureUuids: [], retainedTextureUuids: [uuid], failures: [] }); + expect(mockAssetManager.removeAsset).not.toHaveBeenCalled(); + expect(mockAssetManager.queryAssetUsers).toHaveBeenCalledWith(child); + // Once external references are removed, internal subasset dependencies do not + // prevent exact cleanup. The same candidate can be retried after Clear. + mockAssetManager.queryAssetUsers.mockResolvedValue([child]); + await expect(host.removeLightmapAssets(options)).resolves.toEqual({ deletedTextureUuids: [uuid], retainedTextureUuids: [], failures: [] }); + }); + + it('retains an image when only its subasset dependency query fails', async () => { + const uuid = '11111111-1111-4111-8111-111111111111'; + mockAssetManager.queryAssetInfo.mockReturnValue({ uuid, + url: 'db://assets/Maps/bake-22222222-2222-4222-8222-222222222222/LFX_Mesh_0000.png', + subAssets: { texture: { uuid: `${uuid}@6c48a` } }, + }); + mockAssetManager.queryAssetUsers.mockImplementation(async (id: string) => { + if (id.includes('@')) throw new Error('subasset index unavailable'); + return []; + }); + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid] })).resolves.toEqual({ + deletedTextureUuids: [], retainedTextureUuids: [], failures: [{ uuid, reason: 'subasset index unavailable' }], + }); + expect(mockAssetManager.removeAsset).not.toHaveBeenCalled(); + }); + it('reports dependency query failures without attempting that deletion', async () => { const uuid = '11111111-1111-4111-8111-111111111111'; mockAssetManager.queryAssetInfo.mockReturnValue({ From dba06b7cdfb9bc353ffc49741cbc0412500a6dc5 Mon Sep 17 00:00:00 2001 From: zenos Date: Sat, 12 Sep 2026 10:32:56 +0800 Subject: [PATCH 58/64] =?UTF-8?q?fix/=20=E5=AF=B9=E9=BD=90=E5=85=89?= =?UTF-8?q?=E7=85=A7=E8=B4=B4=E5=9B=BE=E9=98=B6=E6=AE=B5=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=B9=B6=E6=A0=87=E8=AF=86=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/scene/common/lightfx-host.ts | 10 ++++ .../scene/main-process/lightfx-bake-host.ts | 54 +++++++++---------- .../service/baking/lightfx/baker.ts | 2 +- .../service/baking/lightfx/exporter.ts | 9 ++-- .../service/baking/lightfx/scene-stats.ts | 12 +++++ src/core/scene/test/lightfx-bake-host.test.ts | 32 +++++++++-- .../scene/test/lightfx-scene-stats.test.ts | 15 ++++++ 7 files changed, 99 insertions(+), 35 deletions(-) create mode 100644 src/core/scene/scene-process/service/baking/lightfx/scene-stats.ts create mode 100644 src/core/scene/test/lightfx-scene-stats.test.ts diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts index e09bb8db0..64cd9e111 100644 --- a/src/core/scene/common/lightfx-host.ts +++ b/src/core/scene/common/lightfx-host.ts @@ -34,6 +34,8 @@ export interface ILightFXHostCapabilities { /** Native diagnostic data is informational and never controls the bake transaction. */ export interface ILightFXDiagnostics { + /** Internal identity lets terminal readers reject logs from an earlier bake. */ + operationId?: string; version: 1; stage: string; logs: string[]; @@ -58,7 +60,15 @@ export interface IResolvedLightFXTextureSource { fileName: string; } +/** Counts from the actual exported world, not the scene hierarchy or the native debug log. */ +export interface ILightFXSceneStats { + objects: number; + lights: number; + triangles: number; +} + export interface IBeginLightFXBakeOptions { + sceneStats?: ILightFXSceneStats; /** Stable saved scene identity for exact generated-asset cleanup after reopening. */ sceneUuid?: string; outputUrl?: string; diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index 6d55eceb8..1ddecdcc5 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'crypto'; -import { open } from 'fs/promises'; import { appendFile, copy, @@ -49,6 +48,7 @@ type OperationState = 'accepting-input' | 'running' | 'awaiting-commit'; type OperationTerminalState = 'committed' | 'rolled-back' | 'cancelled' | 'expired'; interface LightFXHostOperation { + sceneStats?: IBeginLightFXBakeOptions['sceneStats']; id: string; target: LightFXBakeTarget; sceneName: string; @@ -145,29 +145,9 @@ export class LightFXBakeHost implements ILightFXBakeHostService { } } - /** Read native statistics before the temporary workspace is removed; logging cannot fail a bake. */ - private async readNativeLightmapLog(operation: LightFXHostOperation): Promise { - if (operation.target !== 'lightmap') return; - try { - const file = await open(join(operation.workspace, 'lfx.log'), 'r'); - try { - const buffer = Buffer.alloc(256 * 1024 + 1); - const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); - const text = buffer.subarray(0, Math.min(bytesRead, buffer.length - 1)).toString('utf8'); - const seen = new Set(this.diagnostics.get(operation.id)!.value.logs); - const lines = text.split(/\r?\n/); - if (bytesRead === buffer.length) lines.pop(); - for (const line of lines) { - const clean = this.diagnosticText(operation, line).trim(); - if (clean && !seen.has(clean)) { this.appendLightmapLog(operation, clean); seen.add(clean); } - } - if (bytesRead === buffer.length) this.appendLightmapLog(operation, '[Native log exceeds the preview limit.]'); - } finally { await file.close(); } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - this.appendLightmapLog(operation, '[Unable to read the native baking log.]'); - } - } + private lightmapImagesStage(operation: LightFXHostOperation): void { + const message = 'The baking is ready to complete and begin generating images.'; + if (!this.diagnostics.get(operation.id)!.value.logs.includes(message)) this.appendLightmapLog(operation, message); } public async reserveSceneOperation(options: IReserveLightFXSceneOperationOptions): Promise { @@ -284,6 +264,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { const targetDir = join(parentDir, version); const targetUrl = `${parentUrl}/${version}`; const operation: LightFXHostOperation = { + sceneStats: options.sceneStats ? { ...options.sceneStats } : undefined, id: operationId, target: options.target, sceneName: options.sceneName, @@ -309,7 +290,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { // Reserve the global operation before the first asynchronous filesystem call. this.operation = operation; - this.diagnostics.set(operationId, { owner: { operationId, target: options.target, transactionId: options.transactionId }, value: { version: 1, stage: 'accepting-input', logs: [] } }); + this.diagnostics.set(operationId, { owner: { operationId, target: options.target, transactionId: options.transactionId }, value: { version: 1, operationId, stage: 'accepting-input', logs: [] } }); if (this.diagnostics.size > MAX_REMEMBERED_OPERATIONS) { this.diagnostics.delete(this.diagnostics.keys().next().value!); } if (this.sceneOperation) this.sceneOperation.nativeStarted = true; try { @@ -379,7 +360,12 @@ export class LightFXBakeHost implements ILightFXBakeHostService { onLog: message => { if (this.operation !== operation || operation.terminalState) { return; } console.log(`[LightFX] ${message}`); - if (operation.target === 'lightmap') { this.appendLightmapLog(operation, message); return; } + if (operation.target === 'lightmap') { + // Product logs are assembled from actual progress/export/output below. + // Keep native warnings/errors visible; verbose diagnostics remain in lfx.log. + if (/\b(?:error|warning|failed|failure)\b/i.test(message)) this.appendLightmapLog(operation, message); + return; + } const logs = this.diagnostics.get(operation.id)!.value.logs; logs.push(this.diagnosticText(operation, message)); if (logs.length > 128) { logs.shift(); } @@ -388,16 +374,23 @@ export class LightFXBakeHost implements ILightFXBakeHostService { if (this.operation !== operation || operation.terminalState) { return; } const diagnostic = this.diagnostics.get(operation.id)!.value; diagnostic.progress = this.diagnosticText(operation, progress); - if (operation.target === 'lightmap') this.appendLightmapLog(operation, progress); const rate = parseLightFXProgressRate(progress); + if (operation.target === 'lightmap' && rate !== undefined) { + this.appendLightmapLog(operation, progress); + if (rate === 100) this.lightmapImagesStage(operation); + } if (rate === undefined) { delete diagnostic.rate; } else { diagnostic.rate = rate; } }, }); this.throwIfTerminated(operation); - await this.readNativeLightmapLog(operation); const result = decodeLightFXOutput(await readFile(join(operation.outputDir, 'lfx.out'))); if (operation.target === 'lightmap') { + this.lightmapImagesStage(operation); + if (operation.sceneStats) { + const { objects, lights, triangles } = operation.sceneStats; + this.appendLightmapLog(operation, `Bake scene stats: objects ${objects} lights ${lights} triangles ${triangles}`); + } for (const item of result.meshes) { if (!this.diagnostics.get(operation.id)!.value.logs.some(line => line.startsWith(`Mesh ${item.id}:`))) { this.appendLightmapLog(operation, `Mesh ${item.id}: Index(${item.index}) Offset(${item.offset.join(', ')}) Scale(${item.scale.join(', ')})`); @@ -664,6 +657,11 @@ export class LightFXBakeHost implements ILightFXBakeHostService { throw new Error('Invalid LightFX bake target.'); } this.validateSceneName(options.sceneName); + if (options.sceneStats !== undefined && (!options.sceneStats || + !['objects', 'lights', 'triangles'].every(key => Number.isSafeInteger(options.sceneStats![key as keyof typeof options.sceneStats]) + && options.sceneStats![key as keyof typeof options.sceneStats] >= 0))) { + throw new Error('Invalid LightFX exported scene statistics.'); + } if (options.outputUrl !== undefined) { const url = options.outputUrl; if (options.target !== 'lightmap' || typeof url !== 'string' diff --git a/src/core/scene/scene-process/service/baking/lightfx/baker.ts b/src/core/scene/scene-process/service/baking/lightfx/baker.ts index e4b50999c..ca81604fe 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/baker.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/baker.ts @@ -48,7 +48,7 @@ export class LightFXCoordinator { transactionId, target, sceneName: scene.name, - ...(target === 'lightmap' ? { sceneUuid: scene.uuid } : {}), + ...(target === 'lightmap' ? { sceneUuid: scene.uuid, sceneStats: exported.sceneStats } : {}), textureSources: exported.textureSources, timeoutMs, ...(outputUrl !== undefined ? { outputUrl } : {}), diff --git a/src/core/scene/scene-process/service/baking/lightfx/exporter.ts b/src/core/scene/scene-process/service/baking/lightfx/exporter.ts index 947de7bf2..51e6b004f 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/exporter.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/exporter.ts @@ -1,10 +1,12 @@ -import { DirectionalLight, director, gfx, Light, MeshRenderer, MobilityMode, renderer, Scene, SphereLight, SpotLight, Terrain, Texture2D, Vec3 } from 'cc'; -import type { ILightFXTextureSource } from '../../../../common/lightfx-host'; +import { DirectionalLight, director, gfx, Light, MeshRenderer, MobilityMode, renderer, Scene, SphereLight, SpotLight, Terrain, TERRAIN_BLOCK_TILE_COMPLEXITY, Texture2D, Vec3 } from 'cc'; +import type { ILightFXTextureSource, ILightFXSceneStats } from '../../../../common/lightfx-host'; +import { lightmapSceneStats } from './scene-stats'; import { lightFXBakeHost } from './host'; import { LightFXBakeTarget, LightFXLight, LightFXMaterial, LightFXMesh, LightFXSettings, LightFXTerrain, LightFXWorld } from './types'; import { validLightmapUV } from './lightmap-uv'; export interface LightFXExport { + sceneStats?: ILightFXSceneStats; world: LightFXWorld; models: MeshRenderer[]; terrains: Terrain[]; @@ -37,7 +39,8 @@ export class LightFXExporter { const exposure = hdr ? renderer.scene.Camera.standardExposureValue : 1; for (const light of world.lights) light.color = light.color.map((value) => value * exposure); if (scene.globals.lightProbeInfo.data) for (const probe of scene.globals.lightProbeInfo.data.probes) world.probes.push({ position: [probe.position.x, probe.position.y, probe.position.z], normal: [probe.normal.x, probe.normal.y, probe.normal.z] }); - return { world, models, terrains, stationaryMainLight, textureSources: [...this.textureSources.values()] }; + return { world, models, terrains, stationaryMainLight, textureSources: [...this.textureSources.values()], + ...(target === 'lightmap' ? { sceneStats: lightmapSceneStats(world, TERRAIN_BLOCK_TILE_COMPLEXITY) } : {}) }; } private exportTerrain(terrain: Terrain): LightFXTerrain { diff --git a/src/core/scene/scene-process/service/baking/lightfx/scene-stats.ts b/src/core/scene/scene-process/service/baking/lightfx/scene-stats.ts new file mode 100644 index 000000000..010a468cd --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/scene-stats.ts @@ -0,0 +1,12 @@ +import type { ILightFXSceneStats } from '../../../../common/lightfx-host'; +import type { LightFXWorld } from './types'; + +/** Count only geometry actually exported to LightFX, including two triangles per terrain tile. */ +export function lightmapSceneStats(world: LightFXWorld, terrainBlockTiles: number): ILightFXSceneStats { + return { + objects: world.meshes.length + world.terrains.length, + lights: world.lights.length, + triangles: world.meshes.reduce((sum, mesh) => sum + mesh.triangles.length, 0) + + world.terrains.reduce((sum, terrain) => sum + terrain.blockCount[0] * terrain.blockCount[1] * terrainBlockTiles ** 2 * 2, 0), + }; +} diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts index 81986077a..09ba4be01 100644 --- a/src/core/scene/test/lightfx-bake-host.test.ts +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -138,27 +138,53 @@ describe('LightFXBakeHost', () => { await host.releaseSceneOperation(token); }); - it('keeps Lightmap progress history and reads native statistics before cleanup', async () => { + it('assembles product logs from real progress and exported counts, not the verbose native file', async () => { jest.spyOn(host as any, 'stageLightmapAssets').mockResolvedValue([]); mockRunnerRun.mockImplementationOnce(async ({ cwd, onProgress }: { cwd: string; onProgress: (value: string) => void }) => { onProgress('Build lighting 25%'); onProgress('Build lighting 50%'); onProgress('Build lighting 100%'); - await outputFile(join(cwd, 'lfx.log'), 'Build lighting 100%\nBake scene stats: objects 3 lights 1 triangles 224\n'); + await outputFile(join(cwd, 'lfx.log'), '2026-09-12 Version 382\nCmdLine: /native/LightFX\nStarting thread 0\n'); await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); }); const token = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); - const { operationId } = await host.begin({ ...token, target: 'lightmap', sceneName: 'Scene', textureSources: [], timeoutMs: 120_000 }); + const { operationId } = await host.begin({ ...token, target: 'lightmap', sceneName: 'Scene', textureSources: [], timeoutMs: 120_000, + sceneStats: { objects: 3, lights: 1, triangles: 224 } }); await host.appendInput({ operationId, chunkBase64: Buffer.from('input').toString('base64') }); await host.run({ operationId }); await host.commit({ operationId }); expect((await host.queryDiagnostics({ ...token, operationId, target: 'lightmap' }))?.logs).toEqual([ 'Baking started', 'Build lighting 25%', 'Build lighting 50%', 'Build lighting 100%', + 'The baking is ready to complete and begin generating images.', 'Bake scene stats: objects 3 lights 1 triangles 224', 'End of the baking.', ]); await host.releaseSceneOperation(token); }); + it('does not report successful image generation or statistics when native work fails', async () => { + mockRunnerRun.mockImplementationOnce(async ({ onLog, onProgress }) => { + onLog('Version 382'); + onLog('Warning: native sample warning'); + onProgress('Build lighting 25%'); + throw new Error('native bake failed'); + }); + const token = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const { operationId } = await host.begin({ ...token, target: 'lightmap', sceneName: 'Scene', textureSources: [], timeoutMs: 120_000, + sceneStats: { objects: 2, lights: 1, triangles: 4108 } }); + await host.appendInput({ operationId, chunkBase64: Buffer.from('input').toString('base64') }); + await expect(host.run({ operationId })).rejects.toThrow('native bake failed'); + expect((await host.queryDiagnostics({ ...token, operationId, target: 'lightmap' }))?.logs) + .toEqual(['Baking started', 'Warning: native sample warning', 'Build lighting 25%']); + await host.releaseSceneOperation(token); + }); + + it.each([-1, NaN, 1.5])('rejects malformed exported statistics %s before starting native work', async triangles => { + await expect(host.begin({ target: 'lightmap', sceneName: 'Scene', textureSources: [], timeoutMs: 120_000, + sceneStats: { objects: 2, lights: 1, triangles } })).rejects.toThrow('exported scene statistics'); + expect(mockRunnerRun).not.toHaveBeenCalled(); + expect((await host.queryCapabilities()).busy).toBe(false); + }); + it('reserves before export, rejects missing/wrong ownership and keeps the lease past native commit', async () => { const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); const opts = { target: 'light-probe' as const, sceneName: 'LightProbe', textureSources: [], timeoutMs: 120_000 }; diff --git a/src/core/scene/test/lightfx-scene-stats.test.ts b/src/core/scene/test/lightfx-scene-stats.test.ts new file mode 100644 index 000000000..6da062046 --- /dev/null +++ b/src/core/scene/test/lightfx-scene-stats.test.ts @@ -0,0 +1,15 @@ +import { lightmapSceneStats } from '../scene-process/service/baking/lightfx/scene-stats'; +import type { LightFXWorld } from '../scene-process/service/baking/lightfx/types'; + +describe('Lightmap exported scene statistics', () => { + it('counts mesh triangles plus full terrain tiles, not packed images or terrain tasks', () => { + const world = { meshes: [{ triangles: Array(12) }], terrains: [{ blockCount: [2, 1] }], lights: [{}] } as LightFXWorld; + expect(lightmapSceneStats(world, 32)).toEqual({ objects: 2, lights: 1, triangles: 4108 }); + }); + it('counts mesh-only and empty exported worlds without inventing objects', () => { + const world = { meshes: [{ triangles: Array(12) }, { triangles: Array(200) }, { triangles: Array(12) }], terrains: [], lights: [{}] } as unknown as LightFXWorld; + expect(lightmapSceneStats(world, 32)).toEqual({ objects: 3, lights: 1, triangles: 224 }); + expect(lightmapSceneStats({ meshes: [], terrains: [], lights: [] } as unknown as LightFXWorld, 32)) + .toEqual({ objects: 0, lights: 0, triangles: 0 }); + }); +}); From 01f38706ec1d20c4437e4c1fe55383d33f2b0f92 Mon Sep 17 00:00:00 2001 From: zenos Date: Sat, 12 Sep 2026 10:39:06 +0800 Subject: [PATCH 59/64] =?UTF-8?q?docs/=20=E8=AE=B0=E5=BD=95=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E8=B4=B4=E5=9B=BE=E4=BF=9D=E6=8A=A4=E4=B8=8E=E7=83=98?= =?UTF-8?q?=E7=84=99=E6=97=A5=E5=BF=97=E5=AE=9E=E6=9C=BA=E5=A4=8D=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/scene/lightfx-bake.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 20a71bde5..14eb09e4e 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -407,6 +407,18 @@ LightFX 当前可能输出 Creator 历史协议版本。解析器只接受已知 ## 错误与事务 +### 2026-09-12 重新推进共享引用保护与面板日志 + +用户重新授权处理两项。修改前核对:清理只查询图片主 UUID,但资产依赖索引精确保存 Texture 子 UUID(如 `@6c48a`),导致其他保存场景的引用漏检。最小修复在 LightFX 删除入口同时检查主资源及其实际子资源的使用者,查询失败保留,不改通用依赖 API、不做项目 GC。Clear 与重烘焙共用该检查。 + +面板日志目前直接追加原始 `lfx.log`(时间戳、版本、线程等),缺 Creator 的生成图片阶段及场景统计。计划由实际导出 world 计算对象/灯光/三角形统计,通过内部 Host 诊断传递;真实原生进度100%触发生成图片阶段,实际输出提供UV信息,原始日志文件照常保留,不将其诊断噪声混入产品面板。失败仍显示真实错误,探针日志不改。先类型检查/构建、自动测试,再在新隔离窗口由全局 ui_verifier 核验真实按钮、磁盘文件与另一个场景的有效贴图。 + +实机补充依赖:共享引用保留正确,但原有重烘焙策略仍报告旧产物清理未完成,保存新结果而不固定发布;本次不改变该策略。PinK失败终态仅保留最后轮询的进度,漏掉原生结束日志。最小接入为Host诊断附带内部operationId,PinK终态查询仅接收与启动前不同的本轮诊断,避免GI参数前置拒绝时误取上一轮日志;不新增公开任务或重试Bake。 + +完成:`b21aa0f3` 修复主图/子资源引用保护,`dba06b7c` 实现阶段统计日志和内部任务标识。先 `tsc -b`/Scene bundle,再33套件/565项及定点ESLint通过。PinK同步终态诊断读取,客户端类型检查/构建后15项桥接测试通过。原始日志文件保留详细诊断,产品面板不再读取整份原生日志;以下旧批次关于读取原生日志的记录仅为历史。 + +全局 ui_verifier 实机证据 `/tmp/codex-ui-verifier.nd2IMr`、`/tmp/codex-ui-verifier.CctL3q`,均父代理准备隔离配置/临时工程后交控制并独立核对:共享场景Clear后文件hash保持,真正打开另一场景无粉红/missing,纹理和UV有效;正常GI25/1024生成到固定目录,完整进度及2对象/1灯/4108三角形统计与Creator同场景证据一致。GI65535前置拒绝不串旧日志、不改旧图;正常Clear实际删除本轮PNG/meta和三个辅助文件、绑定UV清空,43点完整SH不变。第二窗口单次重烘焙仍按既有策略因2个共享引用保留而报告清理未完成,但此次失败面板完整显示100%、统计、UV、End及失败;新结果有效保存、共享文件hash保持。保留Terrain UV扩展行,不宣称日志全文完全相同;没有扩大处理导入元数据或既有告警。 + ### Lightmap GI Samples 整数溢出防护(2026-09-12) 已在隔离 PinK 实测 `giSamples=65535` 触发原生 `GenerateIntegrationSamples` 的 vector length_error/SIGABRT。随包 LightFX 对 Lightmap 采样数组长度以有符号32位计算 `giSamples² × 64 × 5`,故最大不溢出整数为2590(不是推荐值,也不保证大值在所有设备上的内存和耗时)。修复仅在公开参数schema、Scene直接入口及输入编码处前置拒绝非法值,不启动原生计算、不修改上一份结果、不静默clamp;Light Probe采样参数保持原契约。用户已决定暂不处理日志对齐,引用保护的隔离失败与用户手测不一致另行保留,不混入本次修复。 From 2dbe96f5a10339cd6752c27349c522d7a1d2a7a1 Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Sat, 12 Sep 2026 12:54:40 +0800 Subject: [PATCH 60/64] fix(lightfx): guard scene sessions and isolate bake assets --- .../scene/history/lightfx-bake-validation.md | 125 +++++++++ docs/dev/scene/lightfx-bake.md | 163 +++--------- src/api/scene/lightfx-bake-schema.ts | 2 +- src/core/assets/manager/filesystem.ts | 14 +- .../assets/test/move-source-failure.test.ts | 22 ++ src/core/scene/common/lightfx-bake.ts | 4 +- .../scene/main-process/lightfx-bake-host.ts | 17 +- .../service/baking/lightfx/scene-context.ts | 25 ++ .../service/core/editor-session.ts | 2 + .../scene/scene-process/service/editor.ts | 16 ++ .../scene-process/service/light-probe-bake.ts | 110 ++++---- .../scene-process/service/lightmap-bake.ts | 249 +++++++++--------- .../service/scene/light-probe-transform.ts | 3 +- src/core/scene/test/editor-save-as.test.ts | 50 ++++ .../scene/test/light-probe-reparent.test.ts | 2 +- .../scene/test/light-probe-transform.test.ts | 9 + .../scene/test/lightfx-asset-versions.test.ts | 98 ++++++- .../test/lightfx-result-failures.test.ts | 24 +- .../test/lightmap-result-recording.test.ts | 7 +- 19 files changed, 623 insertions(+), 319 deletions(-) create mode 100644 docs/dev/scene/history/lightfx-bake-validation.md create mode 100644 src/core/scene/scene-process/service/baking/lightfx/scene-context.ts diff --git a/docs/dev/scene/history/lightfx-bake-validation.md b/docs/dev/scene/history/lightfx-bake-validation.md new file mode 100644 index 000000000..67c18ff0f --- /dev/null +++ b/docs/dev/scene/history/lightfx-bake-validation.md @@ -0,0 +1,125 @@ +# LightFX 历史验证记录 + +以下为整改前的开发与联调记录,仅用于追溯。目录、接口和验收状态以 [当前文档](../lightfx-bake.md) 为准;临时路径不保证仍存在。 + +## 2026-09-11 最新产品决定与实施顺序 + +用户已明确不再为普通重烘焙 Undo 保留旧贴图。成功重烘焙应替换并清理旧产物;Clear 后同样不能恢复旧图/UV/效果,节点移动等普通编辑历史和探针 SH 撤销不变。此决定覆盖本文历史版本关于保留所有成功烘焙版本的描述。 + +当前 `455e8687` 已按场景 UUID 持久记录实际导入的根资产 UUID,Clear 合并当前绑定和已知旧产物,逐项核对引用、删除结果和源文件存在性;不保存历史像素副本,不做项目 GC。 + +本批补齐成功重烘焙的收尾链路:修改场景前校验 Host 的内部 `lightmapRebakeCleanupVersion === 1` 并读取旧候选;新结果应用、录制和保存确认后,使旧 Lightmap 历史失效(保留本次新结果的 Redo 和普通历史),检查实时剩余引用并清理旧候选。Host 只接受仍持有正确 Bake reservation 且原生已 commit 的 `action:bake` 清理。当前新结果、其他字段/场景/材质引用必须保留。删除失败、引用保留或回应不明时返回包含 `New Lightmap result is saved and retained` 的错误,保留已完成的新结果并明确报告,不再恢复旧内存冒充回滚;不能把它解释成 Bake 没有修改场景。 + +`saveScene:false` 不授权删除磁盘已保存场景仍依赖的贴图,不隐式保存,也不删除旧产物或固定发布;成功后的旧结果历史仍失效。保存失败/取消/应用失败不触发旧产物清理。下一次成功保存的 Bake 或删除模式 Clear 可重试已记录产物。`31598b0a` 已接入保存后的固定 PNG 发布;`2b13cfce` 已接入原生配套文件发布与清理,真实面板正常主链路已通过,异常与同场景 Creator 对照仍待专项验收。 + +## Lightmap 资产规则 + +### 原生配套文件接入(实施前记录) + +下一批复用 PNG 的暂存/保存后发布事务:原生完成时将实际 `tmp/lfx.in`、`output/lfx.out` 和存在的 `lfx.log` 导入本轮暂存目录,记录其根 UUID 为独立 auxiliary 成员,不混入贴图预览或纹理数量。保存后发布为 `<根>/tmp/lfx.in`、`<根>/output/lfx.out`、`<根>/lfx.log`。这比延长原生工作目录生命周期更小,commit 仍可按原机制清理工作目录。日志缺失不伪造文件,输入/输出缺失仍按实际失败处理。 + +成功重烘焙和删除式 Clear 清理已记录的旧配套资产;Bake 排除本次 UUID。配套文件不是 Lightmap 绑定,因此不得忽略当前场景对它的其他引用。删除/移动均核对磁盘、UUID 和元数据,固定目标冲突不覆盖。显式不保存和保存失败不提前删旧产物。复用现有归属文件的可选 auxiliary 字段,读写保留旧格式兼容,损坏字段在修改场景前报错;不扫描项目、不按目录猜归属、不留成功历史副本。新增内部能力位约束新旧 Host 混用;公共 MCP 不扩展新入口。 + +实现 `2b13cfce`:上述配套文件以独立 auxiliary UUID 记录,与 PNG 同批预检并移动;返回的 textureUrls 只含 PNG。Scene 在无纹理候选时仍执行 Host 清理以支持辅助资产失败重试,Clear 的资产计数包括辅助文件,绑定计数不变。内部能力位为 `lightmapAuxiliaryAssetsVersion:1`。原生输入文件可能引用临时纹理源,此批未承诺把全部纹理源附件发布成可脱离项目重放的输入包;不扩展为原生工程归档器。 + +先 `tsc -b`/Scene 与 editor-extends 构建,再定点 6 套/144 项、扩展 32 套/535 项通过(`/tmp/pink-native-products-final-tests.log`);定点 ESLint 无代码错误,保留已有配置告警。覆盖实际文件移动、固定冲突前置拒绝、部分移动失败、辅助字段损坏、回滚保留上轮、同场景其他引用保留和无纹理候选重试。 + +隔离工程 `/tmp/pink-native-products.SjK8Ju` 由主 agent 准备新 Host 后交全局 ui_verifier 执行真实按钮,证据 `/tmp/codex-ui-verifier.10oRvd`:128 Bake 产生固定 3 PNG+3 配套文件,256 Bake 替换为 2 PNG+3 新 UUID 配套文件;无缺图,保存成功。Clear 后这 5 个当前资产及 meta 实际不存在,归属 textures/auxiliary 为空,绑定及 UV 清空,43 点 SH 哈希不变,一次 Undo/Redo 未恢复。主 agent 已复核磁盘/JSON/截图。空目录及目录 meta 保留;旧版无归属历史不扫描删除。未直接查询 Asset DB 旧 UUID 缓存,也未在本批重复关闭重开或注入异常;不把正常主链路扩展为所有边界已验收。既有告警及点击 Clear 时瞬时 Console 计数差异均保留原始证据。 + +固定贴图发布的最小接入(实施前记录):继续使用独立临时导入目录完成纹理加载和场景保存;保存确认、旧产物清理完成后,通过 Asset DB 保留 UUID 移动到默认 `db://assets/LightFX/output`,指定 `outputUrl` 时移动到 `/output`。固定发布由原 Bake reservation 和实际 operation ID 校验,不接受任意外部 UUID。所有目标先检查冲突,逐项移动后核对 UUID、URL 和磁盘源/目标;不覆盖同名资产、不先复用旧 UUID。失败保留已保存的新结果位置,不删除新贴图。只用非递归空目录删除收敛已清空的 `bake-UUID`,其他文件存在时保留。`saveScene:false` 暂不固定发布,保护磁盘旧引用。本批不宣称 `lfx.in/out/log` 配套文件已经对齐。 + +Lightmap 先按每次烘焙的 operation UUID 导入到独立暂存目录(以下为省略 `outputUrl` 时的模板),这不是成功后保留的历史版本: + +```text +db://assets//lightmap/bake-/ +``` + +指定 `outputUrl` 时暂存到 `/bake-/`,例如 `db://assets/烘焙结果 Room A`。选择目录必须已存在且真实路径位于当前项目 assets 内;不接受任意磁盘路径、路径穿越或指向 assets 外的符号链接。参数仅改变本次输出位置,不自动保存为场景设置。Scene 的 `queryCapabilities().outputDirectory === true` 来自实际 Host 的 `lightmapOutputDirectory` 支持位;旧 Host 不支持时明确报错,不忽略选择后写入默认目录。 + +保存与旧图清理成功后,当前 PNG 保留 UUID 移动到 `db://assets/LightFX/output`;选择 `db://assets` 与省略参数相同,选择子目录则发布到 `/output`。固定发布额外要求内部 Host 的 `lightmapPublicationVersion === 1`。更新 CLI 后若出现能力不支持错误,需要重启实际 Cocos Host;单独 Reload Window 可能仍连接旧 Host。 + +典型文件包括: + +```text +LFX_Mesh_0000.png +LFX_Terrain_0000.png +``` + +- Mesh 与 Terrain 使用独立的类型和索引映射,避免两者均从索引 0 开始时串绑贴图。 +- 每次生成新 Asset UUID,不直接覆盖已发布像素。保存确认后精确删除旧产物,再把新图移动到固定 URL,不再供旧结果 Undo 使用;`saveScene:false` 的新结果留在独立暂存位置,不会改写或删除磁盘旧场景依赖的贴图。目标仍被占用时明确报错,不覆盖。 +- 旧版平铺目录中的 PNG/`.meta` 原样保留,不自动迁移、不复用其 UUID。调用方必须使用返回的 textureUrls 或真实绑定查询,不拼接固定文件路径。 +- 旧产物从场景归属记录及替换前实时绑定收集,不扫描目录猜测归属。成功保存的 Bake 和删除模式 Clear 会清理无引用候选;引用保留/失败项可以重试。旧版已解绑且从未记录归属的资产不自动猜测删除;已清空的 `bake-UUID` 目录只做非递归删除并刷新 Asset DB,含其他内容的目录保留。 +- 导入后将 `fixAlphaTransparencyArtifacts` 设置为 `false`,再加载 Texture2D 子资源并绑定。 +- 原生提交确认前的导入/加载失败尝试回滚本次新目录;提交确认后不再删除产物。应用失败恢复旧绑定,保存失败保留已录制结果,规则见“提交与保存失败”。旧版本目录不受影响。 +- 成功、失败、取消和超时进入 workspace 清理;回滚或 Asset DB 刷新失败时保留备份和互斥以便恢复,不能宣称所有错误都会完成清理。 + +## 错误与事务 + +### 2026-09-12 重新推进共享引用保护与面板日志 + +用户重新授权处理两项。修改前核对:清理只查询图片主 UUID,但资产依赖索引精确保存 Texture 子 UUID(如 `@6c48a`),导致其他保存场景的引用漏检。最小修复在 LightFX 删除入口同时检查主资源及其实际子资源的使用者,查询失败保留,不改通用依赖 API、不做项目 GC。Clear 与重烘焙共用该检查。 + +面板日志目前直接追加原始 `lfx.log`(时间戳、版本、线程等),缺 Creator 的生成图片阶段及场景统计。计划由实际导出 world 计算对象/灯光/三角形统计,通过内部 Host 诊断传递;真实原生进度100%触发生成图片阶段,实际输出提供UV信息,原始日志文件照常保留,不将其诊断噪声混入产品面板。失败仍显示真实错误,探针日志不改。先类型检查/构建、自动测试,再在新隔离窗口由全局 ui_verifier 核验真实按钮、磁盘文件与另一个场景的有效贴图。 + +实机补充依赖:共享引用保留正确,但原有重烘焙策略仍报告旧产物清理未完成,保存新结果而不固定发布;本次不改变该策略。PinK失败终态仅保留最后轮询的进度,漏掉原生结束日志。最小接入为Host诊断附带内部operationId,PinK终态查询仅接收与启动前不同的本轮诊断,避免GI参数前置拒绝时误取上一轮日志;不新增公开任务或重试Bake。 + +完成:`b21aa0f3` 修复主图/子资源引用保护,`dba06b7c` 实现阶段统计日志和内部任务标识。先 `tsc -b`/Scene bundle,再33套件/565项及定点ESLint通过。PinK同步终态诊断读取,客户端类型检查/构建后15项桥接测试通过。原始日志文件保留详细诊断,产品面板不再读取整份原生日志;以下旧批次关于读取原生日志的记录仅为历史。 + +全局 ui_verifier 实机证据 `/tmp/codex-ui-verifier.nd2IMr`、`/tmp/codex-ui-verifier.CctL3q`,均父代理准备隔离配置/临时工程后交控制并独立核对:共享场景Clear后文件hash保持,真正打开另一场景无粉红/missing,纹理和UV有效;正常GI25/1024生成到固定目录,完整进度及2对象/1灯/4108三角形统计与Creator同场景证据一致。GI65535前置拒绝不串旧日志、不改旧图;正常Clear实际删除本轮PNG/meta和三个辅助文件、绑定UV清空,43点完整SH不变。第二窗口单次重烘焙仍按既有策略因2个共享引用保留而报告清理未完成,但此次失败面板完整显示100%、统计、UV、End及失败;新结果有效保存、共享文件hash保持。保留Terrain UV扩展行,不宣称日志全文完全相同;没有扩大处理导入元数据或既有告警。 + +### Lightmap GI Samples 整数溢出防护(2026-09-12) + +已在隔离 PinK 实测 `giSamples=65535` 触发原生 `GenerateIntegrationSamples` 的 vector length_error/SIGABRT。随包 LightFX 对 Lightmap 采样数组长度以有符号32位计算 `giSamples² × 64 × 5`,故最大不溢出整数为2590(不是推荐值,也不保证大值在所有设备上的内存和耗时)。修复仅在公开参数schema、Scene直接入口及输入编码处前置拒绝非法值,不启动原生计算、不修改上一份结果、不静默clamp;Light Probe采样参数保持原契约。用户已决定暂不处理日志对齐,引用保护的隔离失败与用户手测不一致另行保留,不混入本次修复。 + +产品提交 `21e3b27d`。先通过 `tsc -b` 与 Scene bundle 构建,再通过32套件/556项回归测试及定向 ESLint。全局 ui_verifier 在新夹具 `/tmp/pink-gi-overflow.BNB4DT` 实际执行 GI25 生成 → GI65535 明确拒绝 → 改回25生成成功;拒绝前后场景、PNG、meta 的 hash 及绑定/UV不变,全量43点SH hash一致,两次正常生成均 `dirty:false`、无缺图。Host仅有两次正常任务的 begin/run,65535没有启动原生任务。原始截图、运行时及文件证据 `/tmp/codex-ui-verifier.Uszw6E`。未实机运行2590,不将算术上限当作性能验收;既有告警仍存在。 + +常见错误包括: + +- 当前没有打开已保存场景。 +- 探针不足、未生成或没有可烘焙 Mesh/Terrain。 +- 场景依赖资产缺失。 +- LightFX 缺失、启动失败、连接失败、超时或异常退出。 +- 输出协议不兼容或结果损坏。 +- Asset DB 导入、Texture2D 加载或场景保存失败。 +- 已有另一个 LightFX 任务运行。 +- 当前可见场景尚未加载完成,或同时存在多个可见的场景渲染器。 + +Bake 和非删除模式 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与结果修改的 MeshRenderer/Terrain 组件(同一 Terrain 多 block 去重)及 Scene 标记,不能只录制不递归的 Scene 根节点;旧引擎缺类型的空纹理引用也会保留在快照中。成功自动保存后以该记录作为保存点;saveScene:false 不隐式保存场景。`deleteAssets:true` 是例外:保存成功后只取消本次 Clear 录制,不清空整个 Scene Undo/Redo 历史。恢复快照时使 Clear 前的所有烘焙绑定、UV 和标志失效,而节点移动、其他组件参数及探针系数仍按原历史恢复;即使旧纹理因其他引用保留,也不通过本场景旧快照恢复其烘焙效果。Clear 后新生成的烘焙记录仍可撤销。没有删除候选或全部资产保留时同样推进结果代次而保留普通历史。失败必须区分原生提交前、结果应用中和结果录制后保存阶段,不能对所有错误统一恢复绑定或删除资产,详见“提交与保存失败”。 + +成功 Lightmap Bake 使先前结果历史失效,保存确认后再清理旧像素;本次结果 Redo、普通属性和探针 SH 历史保留。非删除 Clear 仍可撤销恢复未失效的当前结果。`deleteAssets:true` 合并场景归属记录与实际绑定中可验证的 LightFX 根贴图 UUID,不删除整个目录;实时场景或其他磁盘资产仍引用的贴图保留并报告,删除不可撤销。此前已丢失的像素无法靠此修复找回。 + +## 验证范围 + +固定 PNG 发布依赖补充:Asset DB 的普通非覆盖移动原先先移 `.meta`,再移源文件,失败后仍吞错并刷新。仅对非覆盖移动补充错误传播;普通同级移动若源文件尚在、目标文件尚未生成,则无覆盖地回放已移动的元数据,阻止后续刷新生成不同 UUID。覆盖模式不在本次修改范围。该最小共用依赖必须用真实文件与故障注入验证,不能只靠 `moveAsset()` resolve 判成功。 + +默认入口补充:PinK 目录选择器总会传入 `outputUrl`,默认选中 `db://assets` 与省略参数同样发布到 `db://assets/LightFX/output`;选择 assets 内子目录才使用 `/output`。这保证直接接受目录选择器默认值也得到 Creator 风格目录,不要求 UI 绕过既有选择入口。 + +本轮首次 UI 验证发现 PinK 桥接仍硬编码 `saveScene:false`,实际跳过以上发布和旧图清理,虽然面板提示生成成功。该次结果不作为通过证据。PinK 面板入口改为显式保存,并在生成前告知保存/替换语义;CLI 非 UI 调用显式传 `saveScene:false` 仍保持不保存、不删除磁盘旧依赖的安全契约。证据 `/tmp/codex-ui-verifier.aG5Cnt`。 + +固定 PNG 发布 `31598b0a`、非覆盖移动保护 `60f9a975`:先 `tsc -b` 和 Scene/editor-extends 构建,再 **32 套/524 项**通过(`/tmp/pink-fixed-output-final2-tests.log`);定点 ESLint 无代码错误,保留已有 unused catch 和配置警告。PinK `17a0a25b7cb` 接通面板保存式 Bake,客户端类型检查/构建和扩展构建后,15 项 Electron 桥接测试、69 项扩展宿主测试及 1 套编译面板测试通过。实机结果另行记录,不以这些自动测试代替。 + +本轮最终隔离实机 `/tmp/codex-ui-verifier.4fxdtg`:真实面板 128→256 两次 Bake 均发布到 `LightFX/output`,由原生实际打包产生 3→2 张 PNG;旧 PNG/meta 和已空暂存版本目录实际删除、保存场景和运行时 UUID 一致。真实 Clear 后当前 PNG/meta 删除、Mesh 和两个 Terrain block 纹理/UV 清空,节点 X=1 不回退;Scene Undo 节点/最近一条 Bake、Redo Bake/节点不恢复旧图。真实保存、关闭 Scene 标签并从 Assets 重开后仍无绑定/缺图,X=1、dirty=false,43 点完整 SH 哈希不变。主 agent 准备隔离工程与新 Host 后交全局 `ui_verifier` 控制,并独立核对原始数据。旧版未记录归属且已解绑文件未猜删;没有验证所有更早历史、异常/取消/外部引用、禁用 Terrain 或保留历史软重载。原生配套文件固定发布及 Creator 同场景日志/预览对照仍待完成,不将本主链路称为全量产品对齐。已有 dump null/argv.json 告警不宣称消除。 + +前一批 `02f099e7`:成功保存后的旧产物精确清理和旧结果历史失效已接通。先 `tsc -b`/Scene 与 editor-extends 构建,后定点 5 套/120 项、扩展 29 套/472 项通过(`/tmp/pink-rebake-cleanup-final-tests.log`);定点 ESLint 无代码错误,已有配置提示保留。新增测试核对真实临时文件、Host 归属、保存失败/结果不明、当前有效 Redo 和普通历史,不等同实机。以下独立版本完整 Undo 的旧实机记录只作为历史证据,不能作为最新策略验收。 + +2026-09-11 产品对齐补充:Lightmap 日志保留真实 Log/Progress 顺序,原生结束后在临时目录清理前读取 `lfx.log`,补充真实 Mesh/Terrain 输出索引与 UV。日志最多 128 条、每条 2048 字符,原生日志文件读取上限 256 KiB,超限明确提示,缺失日志不伪造成几何统计,也不让烘焙失败。探针日志行为保持原状。此处日志修复不代表原生配套文件已固定发布,也不代替资源删除实机证据。 + +当前实现已经验证: + +- Light Probe Bake/Clear,包含 SH 数据保存和重新加载。 +- Mesh Lightmap Bake/Clear。 +- Terrain Lightmap Bake/Clear。 +- Mesh 与 Terrain 混合场景的独立贴图绑定。 +- 重复烘焙的独立版本目录/UUID、旧像素保留及旧平铺资产兼容。 +- Pink 当前可见场景中的即时结果应用、清理和取消。 +- TypeScript 编译、ESLint、API、协议和资产事务测试。 + +新增材质类型、灯光类型、LightFX 版本或目标平台时,应补充对应真实场景回归。 + +2026-09-10 结果历史专项:macOS arm64/隔离 PinK,真实带第二套 UV 的 Mesh 烘焙 128px 标准/高精度贴图;Bake、保留资产的 Clear、独立 Undo/Redo、渲染模型 UV、显式/自动保存、真正关闭重开通过,旁侧 43 点探针全部 SH 保持。Terrain 多 block 录制目标及失败恢复由服务测试覆盖,未在本次专项重做 Terrain 原生场景实测;也没有验收旧 PNG 像素版本撤销、资产删除撤销或最终画面质量。 + +随后版本隔离专项补验:三次真实 Mesh Bake 使用不同 URL/UUID,标准/高精度 PNG 的 SHA256 随 Undo/Redo 精确对应旧/新结果,关闭重开保留;未保存新 Bake 时磁盘 Scene 仍引用未变更的旧 PNG。旧平铺资产保持。真实文件事务测试覆盖同名场景多次输出互不覆盖、本次回滚/导入失败不影响旧版本;取消故障不作为新增实机验收,资产删除与历史 GC 仍待专门的归属协议。 + +Terrain 专项补验:快照恢复数组后,对已有 TerrainBlock 重新绑定对应 lightmap info(无元素时解绑)并让材质失效,避免 Terrain.onRestore 的 valid 快路径保留旧引用。实际单块和持久化 `.terrain` 双块+Mesh 混合场景,Bake/Clear、Undo/Redo、自动/显式保存、关闭重开通过;每个 block 的实际 texture/UV 与序列化结果一致,43 点探针 SH 不变。`bake().terrainCount` 当前是原生输出 block 条目数,`queryBakeInfo().terrainCount` 是拥有绑定的 Terrain 组件数,两者不应直接比较。地形尺寸/高度保存在 `.terrain` 资产,夹具通过 Terrain.saveManage/saveAssetDialog 正式写入,不靠修改内存后只保存 Scene 冒充持久化。 + +编辑与诊断专项补验(同为 macOS arm64/隔离 PinK):两组 16/27 点切组全选、真实复制/删除按钮、空白/球起手及 Shift 追加框选通过;复制→Undo→改父节点保持组件与全局表一致的 43 点,Undo 恢复原 SH、Redo 恢复新位置与失效状态。自身旋转/非均匀缩放的探针球与采样位置一致,祖先变换同步和 Undo 通过。真实 Probe Bake 显示 `Build lighting 100%`;Mesh+双块 Terrain Bake 观察到 `Build lighting 25%` 后取消,前后结果、历史以及 83 个资产/元数据文件哈希一致。另一场景不接收任务日志。上述不包含持久恢复、安全资产回收、跨磁盘失败原子性或其他 OS 的验收。 diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 14eb09e4e..92aae4bb0 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -46,7 +46,7 @@ const capabilities = await cli.Scene.LightmapBake.queryCapabilities(); ### 原生诊断 -Probe/Lightmap 的 `queryCapabilities()` 和成功 Bake 结果可带 `diagnostics`:`{ version: 1, stage, logs, progress?, rate? }`。Scene 只返回本运行实例、对应烘焙类型的当前或最近原生操作,内部 Host 查询校验 operation ID、target 与 transaction ID;不会返回其他场景的日志。没有可用诊断或查询失败时字段可缺省,集成方应降级显示,不能因此把烘焙成功改为失败。 +Probe/Lightmap 的 `queryCapabilities()` 和成功 Bake 结果可带 `diagnostics`:`{ version: 1, operationId?, stage, logs, progress?, rate? }`。Scene 只返回本运行实例、对应烘焙类型的当前或最近原生操作,内部 Host 查询校验 operation ID、target 与 transaction ID;不会返回其他场景的日志。没有可用诊断或查询失败时字段可缺省,集成方应降级显示,不能因此把烘焙成功改为失败。 Host 最多记住 32 个操作;每个操作保留最近 128 条日志,每条与进度文本上限为 2048 字符,隐藏该操作工作目录和目标资产目录的绝对路径。`progress` 保留 LightFX 原始文本(例如 `Build lighting 25%`);仅当专用 Progress 事件严格匹配该已验证格式且数值位于 0–100 时,另提供 `rate`。未知格式不得从日志或任意数字推断百分比。`stage` 是最近采样的原生阶段,不代替上层 Scene 的成功/取消/恢复状态。进程重启后诊断不保留,不提供持久任务身份或失联事务恢复。 @@ -146,7 +146,7 @@ Scene runtime 先通过内部 `reserveSceneOperation` 取得宿主生成的事 该操作清除当前场景全部探针的烘焙结果,通知引擎刷新,并作为一次 Undo 操作记录。成功结果中的 `probeCount` 表示处理的探针数量。 -Probe Clear 继续保留完整旧 SH 撤销。Lightmap 按用户最新决定收敛:成功重烘焙后不恢复此前旧纹理/UV/场景标记,本次有效结果可以 Redo;删除模式 Clear 后本次及更早结果均失效。不要把探针完整撤销同样改掉。 +Probe Clear 保留完整旧 SH 撤销。Lightmap 的当前契约为:成功重烘焙后不恢复此前旧纹理/UV/场景标记,本次有效结果可以 Redo;删除模式 Clear 后本次及更早结果均失效。两者的 Undo 语义不同。 Probe/Lightmap Bake/Clear 的 `saveScene` 默认是 `true`:完整成功后,当前结果作为已保存基线;Undo 离开保存点会变脏,Redo 回到已保存的有效结果恢复干净。Lightmap 旧结果须经过失效过滤,不能因历史尚存就恢复已被替换的贴图。显式传 `false` 时只修改内存并保留 dirty,调用方需要另行保存。 @@ -191,13 +191,13 @@ Bake 按「确认原生产物提交 → 应用场景结果 → 完成 Undo 录 | 参数 | 范围 | CLI 默认值 | | --- | --- | --- | -| `outputUrl` | 已存在的 `db://assets` 内目录 URL,仅 Lightmap 支持 | `db://assets//lightmap` | +| `outputUrl` | 已存在的 `db://assets` 内父目录 URL,仅 Lightmap 支持 | 发布父目录 `db://assets/LightFX` | | `msaa` | 1、2、4、8 | 4 | | `resolution` | 128、256、512、1024、2048 | 1024 | | `filter` | boolean | `true` | | `highp` | boolean | `false` | | `giScale` | 0–100 | 1 | -| `giSamples` | 1–65535,整数 | 25 | +| `giSamples` | 1–2590,整数 | 25 | | `giPathLength` | 1、2、3、4 | 4 | | `aoLevel` | 0、1、2 | 0 | | `aoStrength` | ≥ 0 | 0.5 | @@ -218,8 +218,8 @@ Bake 按「确认原生产物提交 → 应用场景结果 → 完成 Undo 录 "data": { "sceneUrl": "db://assets/LightProbe.scene", "textureUrls": [ - "db://assets/LightProbe/lightmap/bake-/LFX_Mesh_0000.png", - "db://assets/LightProbe/lightmap/bake-/LFX_Terrain_0000.png" + "db://assets/LightFX/scene-/output/LFX_Mesh_0000.png", + "db://assets/LightFX/scene-/output/LFX_Terrain_0000.png" ], "meshCount": 7, "terrainCount": 1, @@ -249,7 +249,7 @@ Bake 按「确认原生产物提交 → 应用场景结果 → 完成 Undo 录 "textures": [ { "uuid": "texture-asset-uuid", - "url": "db://assets/LightProbe/lightmap/LFX_Mesh_0000.png", + "url": "db://assets/LightFX/scene-/output/LFX_Mesh_0000.png", "filename": "LFX_Mesh_0000.png", "size": 45650, "createdAt": 1788782429000, @@ -285,7 +285,7 @@ Pink 应在场景打开、烘焙完成和清理完成后调用该工具刷新面 } ``` -解除绑定并删除没有其他引用的不可变 LightFX Lightmap 贴图: +解除绑定并删除没有其他引用的 LightFX 贴图及已登记的配套文件: ```json { @@ -296,9 +296,11 @@ Pink 应在场景打开、烘焙完成和清理完成后调用该工具刷新面 } ``` -`saveScene` 默认为 `true`,`deleteAssets` 默认为 `false`。调用 `deleteAssets:true` 前必须确认 `queryCapabilities().assetCleanupVersion === 1`;服务也会在修改场景前再次校验实际 Host 能力。成功结果中的 `clearedCount` 是解除绑定的 Mesh 和 Terrain block 总数,`deletedAssetCount`、`retainedAssetCount` 和 `failedAssetCount` 分别表示删除、因引用保留和删除失败的贴图数量。 +`saveScene` 默认为 `true`,`deleteAssets` 默认为 `false`。调用 `deleteAssets:true` 前必须确认 `queryCapabilities().assetCleanupVersion === 1`;服务也会在修改场景前再次校验实际 Host 能力。成功结果中的 `clearedCount` 是解除绑定的 Mesh 和 Terrain block 总数,`deletedAssetCount`、`retainedAssetCount` 和 `failedAssetCount` 分别表示删除、因引用保留和删除失败的资产数量,包含原生配套文件。 -删除模式先清空绑定,再序列化实时场景检查候选贴图是否仍被其他字段引用;仍存在的根资源或子资源引用会保留。按 2026-09-11 最新 Creator 对齐决定,Clear 不清空节点移动等无关历史:保存成功后只取消 Clear 自身录制,并推进场景级结果代次。Undo/Redo 不恢复任何 Clear 前的 Lightmap 结果(包括未被物理删除的更早 Bake A),但保留普通属性和 SH;Clear 后新的 Bake 历史仍可恢复。快照恢复会清零过期 Mesh/Terrain 绑定、UV 和烘焙标志,同一场景内部重建会转交代次以兼容保留历史的软重载。实际删除的 UUID 另有悬空引用保护;明确保留/失败项解除删除保护,删除结果未知时保守保留。Host 当前仍只逐项删除 Asset DB 可验证的不可变 LightFX 贴图,不删除父目录或同目录其他文件;外部引用、依赖查询失败或删除失败均保留并报告。固定产物布局另行推进,不能据此宣称产物已全面对齐。 +删除模式先清空绑定,再序列化实时场景检查候选资源是否仍被其他字段引用;仍存在的根资源或子资源引用会保留。保存成功后才逐项删除经过 Asset DB 验证、归属明确且没有其他引用的产物,不删除父目录或同目录无关文件。外部引用、依赖查询失败或删除失败均保留并报告。 + +删除模式不清空节点移动等无关历史:保存成功后只取消 Clear 自身录制,并推进场景级结果代次。Undo/Redo 不恢复任何 Clear 前的 Lightmap 结果(包括未被物理删除的更早 Bake),但保留普通属性和 SH;Clear 后新的 Bake 历史仍可恢复。同一场景内部重建会转交代次,以兼容保留历史的软重载。实际删除的 UUID 另有悬空引用保护;明确保留/失败项解除删除保护,删除结果未知时保守保留。 成功 Bake 会替换完整场景结果:先清空旧绑定再应用本次输出,本次未参与的禁用/排除对象不继续展示旧结果;这些对象纳入结果记录及应用失败恢复范围。只有应用/录制/保存失败时才保留旧结果撤销;完整成功后旧 Lightmap 历史失效,普通属性和探针历史不变。 @@ -336,56 +338,20 @@ Scene 侧 `LightProbeBake.cancel()`/`LightmapBake.cancel()` 只取消本 rende 取消成功后,取消工具本身返回 `code: 200`;原烘焙请求结束并返回 `code: 500`、`reason: "LightFX bake was cancelled."`。这是被取消任务的预期终态。 -## 2026-09-11 最新产品决定与实施顺序 - -用户已明确不再为普通重烘焙 Undo 保留旧贴图。成功重烘焙应替换并清理旧产物;Clear 后同样不能恢复旧图/UV/效果,节点移动等普通编辑历史和探针 SH 撤销不变。此决定覆盖本文历史版本关于保留所有成功烘焙版本的描述。 - -当前 `455e8687` 已按场景 UUID 持久记录实际导入的根资产 UUID,Clear 合并当前绑定和已知旧产物,逐项核对引用、删除结果和源文件存在性;不保存历史像素副本,不做项目 GC。 - -本批补齐成功重烘焙的收尾链路:修改场景前校验 Host 的内部 `lightmapRebakeCleanupVersion === 1` 并读取旧候选;新结果应用、录制和保存确认后,使旧 Lightmap 历史失效(保留本次新结果的 Redo 和普通历史),检查实时剩余引用并清理旧候选。Host 只接受仍持有正确 Bake reservation 且原生已 commit 的 `action:bake` 清理。当前新结果、其他字段/场景/材质引用必须保留。删除失败、引用保留或回应不明时返回包含 `New Lightmap result is saved and retained` 的错误,保留已完成的新结果并明确报告,不再恢复旧内存冒充回滚;不能把它解释成 Bake 没有修改场景。 - -`saveScene:false` 不授权删除磁盘已保存场景仍依赖的贴图,不隐式保存,也不删除旧产物或固定发布;成功后的旧结果历史仍失效。保存失败/取消/应用失败不触发旧产物清理。下一次成功保存的 Bake 或删除模式 Clear 可重试已记录产物。`31598b0a` 已接入保存后的固定 PNG 发布;`2b13cfce` 已接入原生配套文件发布与清理,真实面板正常主链路已通过,异常与同场景 Creator 对照仍待专项验收。 - -## Lightmap 资产规则 - -### 原生配套文件接入(实施前记录) - -下一批复用 PNG 的暂存/保存后发布事务:原生完成时将实际 `tmp/lfx.in`、`output/lfx.out` 和存在的 `lfx.log` 导入本轮暂存目录,记录其根 UUID 为独立 auxiliary 成员,不混入贴图预览或纹理数量。保存后发布为 `<根>/tmp/lfx.in`、`<根>/output/lfx.out`、`<根>/lfx.log`。这比延长原生工作目录生命周期更小,commit 仍可按原机制清理工作目录。日志缺失不伪造文件,输入/输出缺失仍按实际失败处理。 - -成功重烘焙和删除式 Clear 清理已记录的旧配套资产;Bake 排除本次 UUID。配套文件不是 Lightmap 绑定,因此不得忽略当前场景对它的其他引用。删除/移动均核对磁盘、UUID 和元数据,固定目标冲突不覆盖。显式不保存和保存失败不提前删旧产物。复用现有归属文件的可选 auxiliary 字段,读写保留旧格式兼容,损坏字段在修改场景前报错;不扫描项目、不按目录猜归属、不留成功历史副本。新增内部能力位约束新旧 Host 混用;公共 MCP 不扩展新入口。 - -实现 `2b13cfce`:上述配套文件以独立 auxiliary UUID 记录,与 PNG 同批预检并移动;返回的 textureUrls 只含 PNG。Scene 在无纹理候选时仍执行 Host 清理以支持辅助资产失败重试,Clear 的资产计数包括辅助文件,绑定计数不变。内部能力位为 `lightmapAuxiliaryAssetsVersion:1`。原生输入文件可能引用临时纹理源,此批未承诺把全部纹理源附件发布成可脱离项目重放的输入包;不扩展为原生工程归档器。 - -先 `tsc -b`/Scene 与 editor-extends 构建,再定点 6 套/144 项、扩展 32 套/535 项通过(`/tmp/pink-native-products-final-tests.log`);定点 ESLint 无代码错误,保留已有配置告警。覆盖实际文件移动、固定冲突前置拒绝、部分移动失败、辅助字段损坏、回滚保留上轮、同场景其他引用保留和无纹理候选重试。 - -隔离工程 `/tmp/pink-native-products.SjK8Ju` 由主 agent 准备新 Host 后交全局 ui_verifier 执行真实按钮,证据 `/tmp/codex-ui-verifier.10oRvd`:128 Bake 产生固定 3 PNG+3 配套文件,256 Bake 替换为 2 PNG+3 新 UUID 配套文件;无缺图,保存成功。Clear 后这 5 个当前资产及 meta 实际不存在,归属 textures/auxiliary 为空,绑定及 UV 清空,43 点 SH 哈希不变,一次 Undo/Redo 未恢复。主 agent 已复核磁盘/JSON/截图。空目录及目录 meta 保留;旧版无归属历史不扫描删除。未直接查询 Asset DB 旧 UUID 缓存,也未在本批重复关闭重开或注入异常;不把正常主链路扩展为所有边界已验收。既有告警及点击 Clear 时瞬时 Console 计数差异均保留原始证据。 - -固定贴图发布的最小接入(实施前记录):继续使用独立临时导入目录完成纹理加载和场景保存;保存确认、旧产物清理完成后,通过 Asset DB 保留 UUID 移动到默认 `db://assets/LightFX/output`,指定 `outputUrl` 时移动到 `/output`。固定发布由原 Bake reservation 和实际 operation ID 校验,不接受任意外部 UUID。所有目标先检查冲突,逐项移动后核对 UUID、URL 和磁盘源/目标;不覆盖同名资产、不先复用旧 UUID。失败保留已保存的新结果位置,不删除新贴图。只用非递归空目录删除收敛已清空的 `bake-UUID`,其他文件存在时保留。`saveScene:false` 暂不固定发布,保护磁盘旧引用。本批不宣称 `lfx.in/out/log` 配套文件已经对齐。 - -Lightmap 先按每次烘焙的 operation UUID 导入到独立暂存目录(以下为省略 `outputUrl` 时的模板),这不是成功后保留的历史版本: - -```text -db://assets//lightmap/bake-/ -``` - -指定 `outputUrl` 时暂存到 `/bake-/`,例如 `db://assets/烘焙结果 Room A`。选择目录必须已存在且真实路径位于当前项目 assets 内;不接受任意磁盘路径、路径穿越或指向 assets 外的符号链接。参数仅改变本次输出位置,不自动保存为场景设置。Scene 的 `queryCapabilities().outputDirectory === true` 来自实际 Host 的 `lightmapOutputDirectory` 支持位;旧 Host 不支持时明确报错,不忽略选择后写入默认目录。 - -保存与旧图清理成功后,当前 PNG 保留 UUID 移动到 `db://assets/LightFX/output`;选择 `db://assets` 与省略参数相同,选择子目录则发布到 `/output`。固定发布额外要求内部 Host 的 `lightmapPublicationVersion === 1`。更新 CLI 后若出现能力不支持错误,需要重启实际 Cocos Host;单独 Reload Window 可能仍连接旧 Host。 - -典型文件包括: - -```text -LFX_Mesh_0000.png -LFX_Terrain_0000.png -``` - -- Mesh 与 Terrain 使用独立的类型和索引映射,避免两者均从索引 0 开始时串绑贴图。 -- 每次生成新 Asset UUID,不直接覆盖已发布像素。保存确认后精确删除旧产物,再把新图移动到固定 URL,不再供旧结果 Undo 使用;`saveScene:false` 的新结果留在独立暂存位置,不会改写或删除磁盘旧场景依赖的贴图。目标仍被占用时明确报错,不覆盖。 -- 旧版平铺目录中的 PNG/`.meta` 原样保留,不自动迁移、不复用其 UUID。调用方必须使用返回的 textureUrls 或真实绑定查询,不拼接固定文件路径。 -- 旧产物从场景归属记录及替换前实时绑定收集,不扫描目录猜测归属。成功保存的 Bake 和删除模式 Clear 会清理无引用候选;引用保留/失败项可以重试。旧版已解绑且从未记录归属的资产不自动猜测删除;已清空的 `bake-UUID` 目录只做非递归删除并刷新 Asset DB,含其他内容的目录保留。 -- 导入后将 `fixAlphaTransparencyArtifacts` 设置为 `false`,再加载 Texture2D 子资源并绑定。 -- 原生提交确认前的导入/加载失败尝试回滚本次新目录;提交确认后不再删除产物。应用失败恢复旧绑定,保存失败保留已录制结果,规则见“提交与保存失败”。旧版本目录不受影响。 -- 成功、失败、取消和超时进入 workspace 清理;回滚或 Asset DB 刷新失败时保留备份和互斥以便恢复,不能宣称所有错误都会完成清理。 +## 场景会话与资产规则 + +- 原生烘焙期间允许打开或重载场景;结果应用前校验启动时的 Scene 实例及编辑器会话代次。同 UUID 重载也视为新会话,旧任务拒绝应用,不保存新场景、不清理旧资产。 +- 结果应用、Undo 录制、保存和清理在原会话的生命周期队列内完成,打开、关闭、重载不会穿插其间。此保护不等同于锁住所有普通属性编辑;烘焙期间仍应避免修改输入几何和灯光。 +- 新产物先导入独立暂存目录:默认 `db://assets//lightmap/bake-/`,指定父目录时为 `/bake-/`。原生提交前失败或取消只回滚本轮产物。 +- 保存并清理旧产物成功后,PNG 保留 UUID 移动到 `<父目录>/scene-<完整场景UUID>/output/`。默认父目录为 `db://assets/LightFX`;`outputUrl: "db://assets"` 与省略相同。相同名称或相同自选父目录的不同场景也互相隔离。调用方必须使用返回的 `textureUrls`,不要拼路径。 +- 同一场景目录内包含 `output/LFX_Mesh_0000.png`、`output/LFX_Terrain_0000.png` 等 PNG,以及实际生成的 `tmp/lfx.in`、`output/lfx.out`、可选 `lfx.log`。配套文件不进入纹理预览列表;输入文件不承诺可脱离项目重放。 +- 新烘焙使用新 UUID,不覆盖同名资产。保存后精确清理旧产物,再发布新文件;冲突或部分移动失败保留已保存的新产物,不伪装成未修改场景。 +- `saveScene:false` 不固定发布、不删除旧资产、不隐式保存。保存失败也不清理旧产物。已确认提交的产物不再用原生 rollback 删除。 +- 归属记录在项目 `settings/lightfx-assets/<场景UUID>.json`,包含 textures 和 auxiliary UUID。旧平铺或自选目录中已记录的产物可以清理;旧版未记录且已解绑的文件不扫描、猜删。 +- 清理检查实时场景引用以及主资源和子资源的外部依赖,逐项确认源文件与 meta 删除。共享资源保留;当前重烘焙若有旧资源保留,仍报告清理未完成并保留已保存结果,不能当作未执行。 +- 已登记的资源在初始化完成、空闲的 Asset DB 中明确不存在时,移除失效归属记录,不计作本次物理删除。数据库未就绪、忙碌或查询抛错时保留记录供重试。 +- 文件移动遇到冲突不覆盖目标;失败时只恢复本次移走且内容未变化的 meta。若源/目标 meta 已被其他操作替换,明确报告安全恢复失败,保留现场,不覆盖别人的元数据。 +- 只非递归删除已清空的 `bake-UUID` 暂存目录;不递归删除输出父目录。损坏归属文件、恢复失败或宿主失联需要排查,不通过手工删记录来解除保护。 ## Creator 互操作说明 @@ -405,73 +371,12 @@ Pink 的烘焙信息面板应使用 `scene-query-lightmap-bake-info`,以当前 LightFX 当前可能输出 Creator 历史协议版本。解析器只接受已知兼容版本,并拒绝未知版本、截断数据、非法长度及非有限浮点数。 -## 错误与事务 - -### 2026-09-12 重新推进共享引用保护与面板日志 - -用户重新授权处理两项。修改前核对:清理只查询图片主 UUID,但资产依赖索引精确保存 Texture 子 UUID(如 `@6c48a`),导致其他保存场景的引用漏检。最小修复在 LightFX 删除入口同时检查主资源及其实际子资源的使用者,查询失败保留,不改通用依赖 API、不做项目 GC。Clear 与重烘焙共用该检查。 - -面板日志目前直接追加原始 `lfx.log`(时间戳、版本、线程等),缺 Creator 的生成图片阶段及场景统计。计划由实际导出 world 计算对象/灯光/三角形统计,通过内部 Host 诊断传递;真实原生进度100%触发生成图片阶段,实际输出提供UV信息,原始日志文件照常保留,不将其诊断噪声混入产品面板。失败仍显示真实错误,探针日志不改。先类型检查/构建、自动测试,再在新隔离窗口由全局 ui_verifier 核验真实按钮、磁盘文件与另一个场景的有效贴图。 - -实机补充依赖:共享引用保留正确,但原有重烘焙策略仍报告旧产物清理未完成,保存新结果而不固定发布;本次不改变该策略。PinK失败终态仅保留最后轮询的进度,漏掉原生结束日志。最小接入为Host诊断附带内部operationId,PinK终态查询仅接收与启动前不同的本轮诊断,避免GI参数前置拒绝时误取上一轮日志;不新增公开任务或重试Bake。 - -完成:`b21aa0f3` 修复主图/子资源引用保护,`dba06b7c` 实现阶段统计日志和内部任务标识。先 `tsc -b`/Scene bundle,再33套件/565项及定点ESLint通过。PinK同步终态诊断读取,客户端类型检查/构建后15项桥接测试通过。原始日志文件保留详细诊断,产品面板不再读取整份原生日志;以下旧批次关于读取原生日志的记录仅为历史。 - -全局 ui_verifier 实机证据 `/tmp/codex-ui-verifier.nd2IMr`、`/tmp/codex-ui-verifier.CctL3q`,均父代理准备隔离配置/临时工程后交控制并独立核对:共享场景Clear后文件hash保持,真正打开另一场景无粉红/missing,纹理和UV有效;正常GI25/1024生成到固定目录,完整进度及2对象/1灯/4108三角形统计与Creator同场景证据一致。GI65535前置拒绝不串旧日志、不改旧图;正常Clear实际删除本轮PNG/meta和三个辅助文件、绑定UV清空,43点完整SH不变。第二窗口单次重烘焙仍按既有策略因2个共享引用保留而报告清理未完成,但此次失败面板完整显示100%、统计、UV、End及失败;新结果有效保存、共享文件hash保持。保留Terrain UV扩展行,不宣称日志全文完全相同;没有扩大处理导入元数据或既有告警。 - -### Lightmap GI Samples 整数溢出防护(2026-09-12) - -已在隔离 PinK 实测 `giSamples=65535` 触发原生 `GenerateIntegrationSamples` 的 vector length_error/SIGABRT。随包 LightFX 对 Lightmap 采样数组长度以有符号32位计算 `giSamples² × 64 × 5`,故最大不溢出整数为2590(不是推荐值,也不保证大值在所有设备上的内存和耗时)。修复仅在公开参数schema、Scene直接入口及输入编码处前置拒绝非法值,不启动原生计算、不修改上一份结果、不静默clamp;Light Probe采样参数保持原契约。用户已决定暂不处理日志对齐,引用保护的隔离失败与用户手测不一致另行保留,不混入本次修复。 - -产品提交 `21e3b27d`。先通过 `tsc -b` 与 Scene bundle 构建,再通过32套件/556项回归测试及定向 ESLint。全局 ui_verifier 在新夹具 `/tmp/pink-gi-overflow.BNB4DT` 实际执行 GI25 生成 → GI65535 明确拒绝 → 改回25生成成功;拒绝前后场景、PNG、meta 的 hash 及绑定/UV不变,全量43点SH hash一致,两次正常生成均 `dirty:false`、无缺图。Host仅有两次正常任务的 begin/run,65535没有启动原生任务。原始截图、运行时及文件证据 `/tmp/codex-ui-verifier.Uszw6E`。未实机运行2590,不将算术上限当作性能验收;既有告警仍存在。 - -常见错误包括: - -- 当前没有打开已保存场景。 -- 探针不足、未生成或没有可烘焙 Mesh/Terrain。 -- 场景依赖资产缺失。 -- LightFX 缺失、启动失败、连接失败、超时或异常退出。 -- 输出协议不兼容或结果损坏。 -- Asset DB 导入、Texture2D 加载或场景保存失败。 -- 已有另一个 LightFX 任务运行。 -- 当前可见场景尚未加载完成,或同时存在多个可见的场景渲染器。 - -Bake 和非删除模式 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与结果修改的 MeshRenderer/Terrain 组件(同一 Terrain 多 block 去重)及 Scene 标记,不能只录制不递归的 Scene 根节点;旧引擎缺类型的空纹理引用也会保留在快照中。成功自动保存后以该记录作为保存点;saveScene:false 不隐式保存场景。`deleteAssets:true` 是例外:保存成功后只取消本次 Clear 录制,不清空整个 Scene Undo/Redo 历史。恢复快照时使 Clear 前的所有烘焙绑定、UV 和标志失效,而节点移动、其他组件参数及探针系数仍按原历史恢复;即使旧纹理因其他引用保留,也不通过本场景旧快照恢复其烘焙效果。Clear 后新生成的烘焙记录仍可撤销。没有删除候选或全部资产保留时同样推进结果代次而保留普通历史。失败必须区分原生提交前、结果应用中和结果录制后保存阶段,不能对所有错误统一恢复绑定或删除资产,详见“提交与保存失败”。 - -成功 Lightmap Bake 使先前结果历史失效,保存确认后再清理旧像素;本次结果 Redo、普通属性和探针 SH 历史保留。非删除 Clear 仍可撤销恢复未失效的当前结果。`deleteAssets:true` 合并场景归属记录与实际绑定中可验证的 LightFX 根贴图 UUID,不删除整个目录;实时场景或其他磁盘资产仍引用的贴图保留并报告,删除不可撤销。此前已丢失的像素无法靠此修复找回。 - -## 验证范围 - -固定 PNG 发布依赖补充:Asset DB 的普通非覆盖移动原先先移 `.meta`,再移源文件,失败后仍吞错并刷新。仅对非覆盖移动补充错误传播;普通同级移动若源文件尚在、目标文件尚未生成,则无覆盖地回放已移动的元数据,阻止后续刷新生成不同 UUID。覆盖模式不在本次修改范围。该最小共用依赖必须用真实文件与故障注入验证,不能只靠 `moveAsset()` resolve 判成功。 - -默认入口补充:PinK 目录选择器总会传入 `outputUrl`,默认选中 `db://assets` 与省略参数同样发布到 `db://assets/LightFX/output`;选择 assets 内子目录才使用 `/output`。这保证直接接受目录选择器默认值也得到 Creator 风格目录,不要求 UI 绕过既有选择入口。 - -本轮首次 UI 验证发现 PinK 桥接仍硬编码 `saveScene:false`,实际跳过以上发布和旧图清理,虽然面板提示生成成功。该次结果不作为通过证据。PinK 面板入口改为显式保存,并在生成前告知保存/替换语义;CLI 非 UI 调用显式传 `saveScene:false` 仍保持不保存、不删除磁盘旧依赖的安全契约。证据 `/tmp/codex-ui-verifier.aG5Cnt`。 - -固定 PNG 发布 `31598b0a`、非覆盖移动保护 `60f9a975`:先 `tsc -b` 和 Scene/editor-extends 构建,再 **32 套/524 项**通过(`/tmp/pink-fixed-output-final2-tests.log`);定点 ESLint 无代码错误,保留已有 unused catch 和配置警告。PinK `17a0a25b7cb` 接通面板保存式 Bake,客户端类型检查/构建和扩展构建后,15 项 Electron 桥接测试、69 项扩展宿主测试及 1 套编译面板测试通过。实机结果另行记录,不以这些自动测试代替。 - -本轮最终隔离实机 `/tmp/codex-ui-verifier.4fxdtg`:真实面板 128→256 两次 Bake 均发布到 `LightFX/output`,由原生实际打包产生 3→2 张 PNG;旧 PNG/meta 和已空暂存版本目录实际删除、保存场景和运行时 UUID 一致。真实 Clear 后当前 PNG/meta 删除、Mesh 和两个 Terrain block 纹理/UV 清空,节点 X=1 不回退;Scene Undo 节点/最近一条 Bake、Redo Bake/节点不恢复旧图。真实保存、关闭 Scene 标签并从 Assets 重开后仍无绑定/缺图,X=1、dirty=false,43 点完整 SH 哈希不变。主 agent 准备隔离工程与新 Host 后交全局 `ui_verifier` 控制,并独立核对原始数据。旧版未记录归属且已解绑文件未猜删;没有验证所有更早历史、异常/取消/外部引用、禁用 Terrain 或保留历史软重载。原生配套文件固定发布及 Creator 同场景日志/预览对照仍待完成,不将本主链路称为全量产品对齐。已有 dump null/argv.json 告警不宣称消除。 - -前一批 `02f099e7`:成功保存后的旧产物精确清理和旧结果历史失效已接通。先 `tsc -b`/Scene 与 editor-extends 构建,后定点 5 套/120 项、扩展 29 套/472 项通过(`/tmp/pink-rebake-cleanup-final-tests.log`);定点 ESLint 无代码错误,已有配置提示保留。新增测试核对真实临时文件、Host 归属、保存失败/结果不明、当前有效 Redo 和普通历史,不等同实机。以下独立版本完整 Undo 的旧实机记录只作为历史证据,不能作为最新策略验收。 - -2026-09-11 产品对齐补充:Lightmap 日志保留真实 Log/Progress 顺序,原生结束后在临时目录清理前读取 `lfx.log`,补充真实 Mesh/Terrain 输出索引与 UV。日志最多 128 条、每条 2048 字符,原生日志文件读取上限 256 KiB,超限明确提示,缺失日志不伪造成几何统计,也不让烘焙失败。探针日志行为保持原状。此处日志修复不代表原生配套文件已固定发布,也不代替资源删除实机证据。 - -当前实现已经验证: - -- Light Probe Bake/Clear,包含 SH 数据保存和重新加载。 -- Mesh Lightmap Bake/Clear。 -- Terrain Lightmap Bake/Clear。 -- Mesh 与 Terrain 混合场景的独立贴图绑定。 -- 重复烘焙的独立版本目录/UUID、旧像素保留及旧平铺资产兼容。 -- Pink 当前可见场景中的即时结果应用、清理和取消。 -- TypeScript 编译、ESLint、API、协议和资产事务测试。 - -新增材质类型、灯光类型、LightFX 版本或目标平台时,应补充对应真实场景回归。 - -2026-09-10 结果历史专项:macOS arm64/隔离 PinK,真实带第二套 UV 的 Mesh 烘焙 128px 标准/高精度贴图;Bake、保留资产的 Clear、独立 Undo/Redo、渲染模型 UV、显式/自动保存、真正关闭重开通过,旁侧 43 点探针全部 SH 保持。Terrain 多 block 录制目标及失败恢复由服务测试覆盖,未在本次专项重做 Terrain 原生场景实测;也没有验收旧 PNG 像素版本撤销、资产删除撤销或最终画面质量。 - -随后版本隔离专项补验:三次真实 Mesh Bake 使用不同 URL/UUID,标准/高精度 PNG 的 SHA256 随 Undo/Redo 精确对应旧/新结果,关闭重开保留;未保存新 Bake 时磁盘 Scene 仍引用未变更的旧 PNG。旧平铺资产保持。真实文件事务测试覆盖同名场景多次输出互不覆盖、本次回滚/导入失败不影响旧版本;取消故障不作为新增实机验收,资产删除与历史 GC 仍待专门的归属协议。 - -Terrain 专项补验:快照恢复数组后,对已有 TerrainBlock 重新绑定对应 lightmap info(无元素时解绑)并让材质失效,避免 Terrain.onRestore 的 valid 快路径保留旧引用。实际单块和持久化 `.terrain` 双块+Mesh 混合场景,Bake/Clear、Undo/Redo、自动/显式保存、关闭重开通过;每个 block 的实际 texture/UV 与序列化结果一致,43 点探针 SH 不变。`bake().terrainCount` 当前是原生输出 block 条目数,`queryBakeInfo().terrainCount` 是拥有绑定的 Terrain 组件数,两者不应直接比较。地形尺寸/高度保存在 `.terrain` 资产,夹具通过 Terrain.saveManage/saveAssetDialog 正式写入,不靠修改内存后只保存 Scene 冒充持久化。 +## main 合并兼容性与验证 -编辑与诊断专项补验(同为 macOS arm64/隔离 PinK):两组 16/27 点切组全选、真实复制/删除按钮、空白/球起手及 Shift 追加框选通过;复制→Undo→改父节点保持组件与全局表一致的 43 点,Undo 恢复原 SH、Redo 恢复新位置与失效状态。自身旋转/非均匀缩放的探针球与采样位置一致,祖先变换同步和 Undo 通过。真实 Probe Bake 显示 `Build lighting 100%`;Mesh+双块 Terrain Bake 观察到 `Build lighting 25%` 后取消,前后结果、历史以及 83 个资产/元数据文件哈希一致。另一场景不接收任务日志。上述不包含持久恢复、安全资产回收、跨磁盘失败原子性或其他 OS 的验收。 +- `Scene.Gizmo.deleteSelectedLightProbes()` 和 `duplicateSelectedLightProbes()` 返回 `Promise`,调用方须 `await` 后读取数量。 +- 地形保存异常会阻止场景保存,批量成功项不掩盖失败项;这修正了之前吞错的行为。 +- 普通无生成探针的场景跳过新增子树扫描。探针同步、结果历史保护仍集中于专用辅助模块,公共入口保留调用。 +- 烘焙目录结构已按场景 UUID 隔离;更新 CLI 后重启实际 Node Host 和 Scene runtime,不混用旧产物。 +- 自动回归覆盖场景切换/同 UUID 重载、生命周期队列内保存、双场景同目录重烘焙/清理、旧目录迁移、已删配套文件重试和 meta 冲突恢复。 +- Pink 手动验证:A 场景开始烘焙后打开/重载场景,确认旧任务拒绝写回;A/B 使用相同输出父目录分别 Bake,重烘焙并清理 A,确认 B 不变;删除生成的 log 后重试;最后验证普通节点编辑、Undo/Redo、Terrain 保存和关闭重开。 +- 自动测试不替代当前版本的原生烘焙与画面验收。历史联调证据见 [历史验证记录](history/lightfx-bake-validation.md),不作为当前版本全量通过的证明。 diff --git a/src/api/scene/lightfx-bake-schema.ts b/src/api/scene/lightfx-bake-schema.ts index 6e57bccf0..f092775c8 100644 --- a/src/api/scene/lightfx-bake-schema.ts +++ b/src/api/scene/lightfx-bake-schema.ts @@ -25,7 +25,7 @@ export const SchemaLightProbeBakeResult = z.object({ }); export const SchemaLightmapBakeOptions = z.object({ - outputUrl: z.string().optional().describe('Existing output directory under db://assets; each bake creates an immutable child directory. Defaults to the scene lightmap directory.'), + outputUrl: z.string().optional().describe('Existing parent directory under db://assets. Saved results publish into scene-/output; omitted or db://assets uses db://assets/LightFX as parent. Use returned texture URLs.'), msaa: z.union([z.literal(1), z.literal(2), z.literal(4), z.literal(8)]).optional(), resolution: z.union([z.literal(128), z.literal(256), z.literal(512), z.literal(1024), z.literal(2048)]).optional(), filter: z.boolean().optional(), highp: z.boolean().optional(), diff --git a/src/core/assets/manager/filesystem.ts b/src/core/assets/manager/filesystem.ts index 5ad13d2d5..a6a3c7cfd 100644 --- a/src/core/assets/manager/filesystem.ts +++ b/src/core/assets/manager/filesystem.ts @@ -143,14 +143,24 @@ export async function moveAssetSource(source: string, target: string, options?: try { if (!Utils.Path.contains(source, target)) { + const originalMeta = !renameOptions.overwrite ? Buffer.from(await readPath(source + '.meta')) : undefined; await renamePath(source + '.meta', target + '.meta', renameOptions); try { await renamePath(source, target, renameOptions); } catch (error) { // Keep the original UUID when a non-overwriting source move fails. // Propagate failure before Asset DB refresh can generate a replacement meta. - if (!renameOptions.overwrite && existsSync(source) && !existsSync(target)) { - await renamePath(target + '.meta', source + '.meta', { overwrite: false }); + if (originalMeta && existsSync(source)) { + try { + // A competing destination PNG does not own our metadata. Restore only + // the exact bytes moved by this operation, never a replacement meta. + if (existsSync(source + '.meta') || !Buffer.from(await readPath(target + '.meta')).equals(originalMeta)) { + throw new Error('Asset metadata changed during the failed move; manual recovery is required.'); + } + await renamePath(target + '.meta', source + '.meta', { overwrite: false }); + } catch (recoveryError) { + throw new AggregateError([error, recoveryError], 'Asset move failed and original metadata could not be restored safely.'); + } } throw error; } diff --git a/src/core/assets/test/move-source-failure.test.ts b/src/core/assets/test/move-source-failure.test.ts index 75cf912bf..4eb3bf2f6 100644 --- a/src/core/assets/test/move-source-failure.test.ts +++ b/src/core/assets/test/move-source-failure.test.ts @@ -46,4 +46,26 @@ describe('non-overwriting asset source move failure', () => { expect(await pathExists(source)).toBe(false); expect(await pathExists(`${source}.meta`)).toBe(false); }); + it('restores the source metadata even when a competing target PNG appears', async () => { + setFileSystemProvider({ rename: async (from, to, options) => { + if (from === source) await outputFile(target, 'unrelated pixels'); + await move(from, to, { overwrite: !!options?.overwrite }); + } }); + await expect(moveAssetSource(source, target, { overwrite: false })).rejects.toThrow(); + expect(await readFile(target, 'utf8')).toBe('unrelated pixels'); + expect(await readFile(`${source}.meta`, 'utf8')).toBe('{"uuid":"original"}'); + expect(await pathExists(`${target}.meta`)).toBe(false); + }); + it.each(['source', 'target'])('never overwrites a replacement %s metadata during recovery', async location => { + setFileSystemProvider({ rename: async (from, to, options) => { + if (from === source) { + await outputFile(`${location === 'source' ? source : target}.meta`, 'unrelated meta'); + throw new Error('PNG move denied'); + } + await move(from, to, { overwrite: !!options?.overwrite }); + } }); + await expect(moveAssetSource(source, target, { overwrite: false })).rejects.toThrow('could not be restored safely'); + expect(await readFile(`${location === 'source' ? source : target}.meta`, 'utf8')).toBe('unrelated meta'); + expect(await readFile(source, 'utf8')).toBe('new pixels'); + }); }); diff --git a/src/core/scene/common/lightfx-bake.ts b/src/core/scene/common/lightfx-bake.ts index 7b6f747e9..731d97c5d 100644 --- a/src/core/scene/common/lightfx-bake.ts +++ b/src/core/scene/common/lightfx-bake.ts @@ -44,7 +44,7 @@ export interface ILightProbeBakeResult { } export interface ILightmapBakeOptions { - /** Existing assets directory URL. Each bake publishes an immutable child directory; omitted uses the scene's default. */ + /** Existing assets parent directory. Saved results publish to scene-/output; omitted uses db://assets/LightFX. */ outputUrl?: string; msaa?: 1 | 2 | 4 | 8; resolution?: 128 | 256 | 512 | 1024 | 2048; @@ -71,7 +71,7 @@ export interface ILightmapBakeCapabilities { /** Mesh/Terrain bindings, null references and live blocks are restored with the result history. */ resultLifecycleVersion: 1; sceneTransactionVersion: 1; - /** The actual host preserves previous textures in immutable per-operation directories. */ + /** The actual host stages new UUIDs separately before saving and replacing prior outputs. */ assetVersion: 1; /** Clear saves first, then deletes exact unreferenced immutable LightFX texture assets. */ assetCleanupVersion?: 1; diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts index 1ddecdcc5..abfdf1248 100644 --- a/src/core/scene/main-process/lightfx-bake-host.ts +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -36,7 +36,7 @@ import type { ILightFXDiagnostics, IPublishLightmapAssetsOptions, } from '../common/lightfx-host'; -import { assetManager } from '../../assets'; +import { assetDBManager, assetManager } from '../../assets'; import type { IAssetInfo } from '../../assets/@types/public'; import { LightmapAssetTransaction } from './lightfx/asset-transaction'; import { LightmapAssetRecord } from './lightfx/asset-record'; @@ -263,6 +263,9 @@ export class LightFXBakeHost implements ILightFXBakeHostService { const parentDir = join(assetRoot, parentUrl.slice('db://assets'.length)); const targetDir = join(parentDir, version); const targetUrl = `${parentUrl}/${version}`; + const publicationBase = options.outputUrl && options.outputUrl !== 'db://assets' ? options.outputUrl : 'db://assets/LightFX'; + const publicationIdentity = options.sceneUuid + ? `scene-${Utils.UUID.decompressUUID(options.sceneUuid).split('@', 1)[0]}` : version; const operation: LightFXHostOperation = { sceneStats: options.sceneStats ? { ...options.sceneStats } : undefined, id: operationId, @@ -274,7 +277,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { outputDir, targetDir, targetUrl, - publicationRootUrl: options.outputUrl && options.outputUrl !== 'db://assets' ? options.outputUrl : 'db://assets/LightFX', + publicationRootUrl: `${publicationBase}/${publicationIdentity}`, refreshUrl: options.outputUrl ?? `db://assets/${options.sceneName}`, inputBytes: 0, inputWritePromise: Promise.resolve(), @@ -569,12 +572,20 @@ export class LightFXBakeHost implements ILightFXBakeHostService { // delete can be retried after the saved scene no longer has any Lightmap binding. if (known.length) await record.add(known); const result: IRemoveLightmapAssetsResult = { deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }; + const absent: string[] = []; if (auxiliary.size) { result.deletedAuxiliaryAssetUuids = []; result.retainedAuxiliaryAssetUuids = []; } for (const uuid of candidates) { const info = infos.get(uuid); + // Only an authoritative miss in an initialized, idle Asset DB invalidates + // membership. Exceptions, startup and refresh gaps must retain the record. + if (!info && (recorded.has(uuid) || auxiliary.has(uuid)) && assetDBManager?.ready + && !assetDBManager.isBusy() && assetManager.queryAssetInfo(uuid) === null) { + absent.push(uuid); + continue; + } if (!info || !managed(uuid)) { result.failures.push({ uuid, reason: 'Asset is not a managed LightFX texture.' }); continue; @@ -623,7 +634,7 @@ export class LightFXBakeHost implements ILightFXBakeHostService { result.failures.push({ uuid, reason: error instanceof Error ? error.message : String(error) }); } } - const deleted = [...result.deletedTextureUuids, ...(result.deletedAuxiliaryAssetUuids ?? [])]; + const deleted = [...result.deletedTextureUuids, ...(result.deletedAuxiliaryAssetUuids ?? []), ...absent]; if (deleted.length > 0) { await record.forget(deleted); } diff --git a/src/core/scene/scene-process/service/baking/lightfx/scene-context.ts b/src/core/scene/scene-process/service/baking/lightfx/scene-context.ts new file mode 100644 index 000000000..5b341c118 --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/scene-context.ts @@ -0,0 +1,25 @@ +import { director, type Scene } from 'cc'; +import { Service } from '../../core'; +import type { IEditorSessionService } from '../../core/editor-session'; + +/** Pin both the saved editor session and the actual Scene, including same-URL reloads. */ +export function captureLightFXScene(scene: Scene) { + const editor = Service.Editor as unknown as IEditorSessionService; + const session = editor.getEditorSession(); + const assertCurrent = () => { + if (director.getScene() !== scene || !editor.isCurrentEditorSession(session)) { + throw new Error('The source scene changed during the LightFX operation. Retry in the intended scene.'); + } + }; + assertCurrent(); + return { + assertCurrent, + // Only result application/save/cleanup holds the lifecycle queue; native work does not. + run(operation: (save: () => Promise) => Promise): Promise { + return editor.runForSession(session, async save => { + assertCurrent(); + return operation(async () => { assertCurrent(); return save(); }); + }); + }, + }; +} diff --git a/src/core/scene/scene-process/service/core/editor-session.ts b/src/core/scene/scene-process/service/core/editor-session.ts index 6c6eb9704..39b5aff88 100644 --- a/src/core/scene/scene-process/service/core/editor-session.ts +++ b/src/core/scene/scene-process/service/core/editor-session.ts @@ -10,4 +10,6 @@ export interface IEditorSessionService { getEditorSession(): IEditorSessionSnapshot; isCurrentEditorSession(session: IEditorSessionSnapshot): boolean; reloadForSession(params: IReloadOptions, session: IEditorSessionSnapshot): Promise; + /** Serialize a short result transaction with open/close/reload. Use the supplied save, not Editor.save. */ + runForSession(session: IEditorSessionSnapshot, operation: (save: () => Promise) => Promise): Promise; } diff --git a/src/core/scene/scene-process/service/editor.ts b/src/core/scene/scene-process/service/editor.ts index 0779d3d8e..9adb024e7 100644 --- a/src/core/scene/scene-process/service/editor.ts +++ b/src/core/scene/scene-process/service/editor.ts @@ -96,6 +96,22 @@ export class EditorService extends BaseService implements IEditor && this.isOpen; } + public runForSession(session: IEditorSessionSnapshot, operation: (save: () => Promise) => Promise): Promise { + return this.runLifecycle(async () => { + const assertCurrent = () => { + if (!session.uuid || !this.isCurrentEditorSession(session)) { + throw new Error('The source scene session changed before its result could be applied.'); + } + }; + assertCurrent(); + return operation(async () => { + assertCurrent(); + // Already inside the lifecycle queue. Re-entering save() would deadlock. + return this.saveUnlocked({ urlOrUUID: session.uuid! }); + }); + }); + } + private invalidateEditorSession(): void { this.editorSessionGeneration++; } diff --git a/src/core/scene/scene-process/service/light-probe-bake.ts b/src/core/scene/scene-process/service/light-probe-bake.ts index 3016fb8fe..9bbf1b3ce 100644 --- a/src/core/scene/scene-process/service/light-probe-bake.ts +++ b/src/core/scene/scene-process/service/light-probe-bake.ts @@ -13,6 +13,7 @@ import { lightFXSceneOperation } from './baking/lightfx/scene-operation'; import { lightFXBakeHost } from './baking/lightfx/host'; import { finishSavedLightFXRecording, LightFXResultRetainedError } from './baking/lightfx/saved-recording'; import { BaseService, register, Service } from './core'; +import { captureLightFXScene } from './baking/lightfx/scene-context'; interface ProbeSnapshot { normal: Vec3; @@ -50,7 +51,9 @@ export class LightProbeBakeService extends BaseService imple const scene = director.getScene() as Scene | null; if (!scene) throw new Error('No scene is currently open.'); + const context = captureLightFXScene(scene); const sceneUrl = await this.querySceneUrl(); + context.assertCurrent(); const info: any = scene.globals.lightProbeInfo; const probes: any[] = info.data?.probes ?? []; if (probes.length < 4) throw new Error('At least four generated light probes are required.'); @@ -73,45 +76,46 @@ export class LightProbeBakeService extends BaseService imple const previous = this.snapshot(probes); let output: LightFXBakeOutput | undefined; let nativeCommitted = false; - let applying = false; this.broadcast('lightfx:bake-start', 'light-probe'); try { output = await lightFXCoordinator.bake(scene, 'light-probe', settings, options.timeoutMs ?? 600_000); - this.validateResult(probes, output); - await lightFXCoordinator.commit(output.operationId); - nativeCommitted = true; - - const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Bake light probes' }); - try { - applying = true; - this.applySettings(info, settingsToApply); - this.applyResult(probes, output); - info.onProbeBakeFinished(); - await Service.Engine.repaintInEditMode(); - await finishSavedLightFXRecording(Service.Undo, undo, - options.saveScene !== false ? () => Service.Editor.save({}) : undefined); - applying = false; - } catch (error) { - if (!(error instanceof LightFXResultRetainedError)) Service.Undo.cancelRecording(undo); - throw error; - } - - this.broadcast('lightfx:bake-end', 'light-probe'); - return { - sceneUrl, - probeCount: probes.length, - ...settingsToApply, - durationMs: Date.now() - started, - diagnostics: await lightFXCoordinator.queryDiagnostics?.('light-probe'), - }; + const completed = output; + return await context.run(async save => { + const output = completed; + this.validateResult(probes, output); + await lightFXCoordinator.commit(output.operationId); + nativeCommitted = true; + + const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Bake light probes' }); + try { + this.applySettings(info, settingsToApply); + this.applyResult(probes, output); + info.onProbeBakeFinished(); + await Service.Engine.repaintInEditMode(); + await finishSavedLightFXRecording(Service.Undo, undo, + options.saveScene !== false ? save : undefined); + } catch (error) { + if (!(error instanceof LightFXResultRetainedError)) { + Service.Undo.cancelRecording(undo); + this.restore(probes, previous); + this.applySettings(info, previousSettings); + info.onProbeBakeFinished(); + await Service.Engine.repaintInEditMode(); + } + throw error; + } + + this.broadcast('lightfx:bake-end', 'light-probe'); + return { + sceneUrl, + probeCount: probes.length, + ...settingsToApply, + durationMs: Date.now() - started, + diagnostics: await lightFXCoordinator.queryDiagnostics?.('light-probe'), + }; + }); } catch (error) { if (output && !nativeCommitted) await lightFXCoordinator.rollback(output.operationId).catch(() => undefined); - if (applying && !(error instanceof LightFXResultRetainedError)) { - this.restore(probes, previous); - this.applySettings(info, previousSettings); - info.onProbeBakeFinished(); - await Service.Engine.repaintInEditMode(); - } this.broadcast('lightfx:bake-end', 'light-probe', this.errorMessage(error)); throw error; } @@ -124,24 +128,26 @@ export class LightProbeBakeService extends BaseService imple private async clearBakeExclusive(options: { saveScene?: boolean }): Promise<{ probeCount: number }> { const scene = director.getScene(); if (!scene) throw new Error('No scene is currently open.'); - const info: any = scene.globals.lightProbeInfo; - const probes: any[] = info.data?.probes ?? []; - const previous = this.snapshot(probes); - const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Clear light probes' }); - try { - info.onProbeBakeCleared(); - await Service.Engine.repaintInEditMode(); - await finishSavedLightFXRecording(Service.Undo, undo, - options.saveScene !== false ? () => Service.Editor.save({}) : undefined); - return { probeCount: probes.length }; - } catch (error) { - if (error instanceof LightFXResultRetainedError) throw error; - Service.Undo.cancelRecording(undo); - this.restore(probes, previous); - info.onProbeBakeFinished(); - await Service.Engine.repaintInEditMode(); - throw error; - } + return captureLightFXScene(scene).run(async save => { + const info: any = scene.globals.lightProbeInfo; + const probes: any[] = info.data?.probes ?? []; + const previous = this.snapshot(probes); + const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Clear light probes' }); + try { + info.onProbeBakeCleared(); + await Service.Engine.repaintInEditMode(); + await finishSavedLightFXRecording(Service.Undo, undo, + options.saveScene !== false ? save : undefined); + return { probeCount: probes.length }; + } catch (error) { + if (error instanceof LightFXResultRetainedError) throw error; + Service.Undo.cancelRecording(undo); + this.restore(probes, previous); + info.onProbeBakeFinished(); + await Service.Engine.repaintInEditMode(); + throw error; + } + }); } cancel(): Promise { diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index f92d86d88..35f74e8b5 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -14,6 +14,7 @@ import { deletedLightmapAssets } from './baking/lightfx/deleted-lightmap-assets' import { BaseService, register, Service } from './core'; import { loadPreviewAsset } from './preview/asset-reload'; import { validateLightmapGISamples } from '../../common/lightfx-limits'; +import { captureLightFXScene } from './baking/lightfx/scene-context'; interface LightmapBinding { target: any; @@ -47,6 +48,7 @@ export class LightmapBakeService extends BaseService impleme const scene = director.getScene() as Scene | null; if (!scene) throw new Error('No scene is currently open.'); + const context = captureLightFXScene(scene); const sceneUrl = await this.querySceneUrl(); // Preflight before native publication or scene mutation, not after a successful save. const capabilities = await lightFXBakeHost.queryCapabilities(); @@ -55,6 +57,7 @@ export class LightmapBakeService extends BaseService impleme throw new Error('The LightFX host does not support current Lightmap publication and cleanup. Restart the Cocos host after updating the CLI; reloading only the window may keep the old host.'); } const owned = (await lightFXBakeHost.queryLightmapTextureInfo({ uuids: [], sceneUuid: scene.uuid })).ownedTextureUuids ?? []; + context.assertCurrent(); const settings = createDefaultLightFXSettings('lightmap'); Object.assign(settings, { msaa: options.msaa ?? settings.msaa, @@ -83,65 +86,69 @@ export class LightmapBakeService extends BaseService impleme const targetUrl = `db://assets/${scene.name}/lightmap`; const textures = await this.loadOutputTextures(output, targetUrl, timeoutMs); - // No scene/history/disk reference may precede the host's decision to retain assets. - // An unconfirmed commit can leave an orphan version, never a dangling scene binding. - await lightFXCoordinator.commit(output.operationId); - nativeCommitted = true; - const previousBindings = this.snapshotSceneBindings(scene); - const previousTextureUuids = [...new Set([...owned, ...previousBindings.map(binding => binding.texture?.uuid ?? (binding.texture as any)?._uuid)] - .filter((uuid): uuid is string => typeof uuid === 'string' && uuid.length > 0) - .map(uuid => this.rootAssetUuid(uuid)))]; - const affectedBindings = [...previousBindings, ...this.snapshotBindings(output)]; - const previousHighp = (scene.globals as any).bakedWithHighpLightmap; - const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; - // Scene recordings do not recursively capture child components. - // Keep the flags last, after restoring each affected result binding. - const targets = [...new Set([...output.models, ...output.terrains, ...previousBindings.map(binding => binding.target)] - .map(component => component.uuid)), scene.uuid]; - const undo = Service.Undo.beginRecording(targets, { label: 'Bake lightmap' }); - const replacement = deletedLightmapAssets.beginReplacement(scene); - try { - // A successful bake replaces the complete result, including disabled objects - // that were excluded from this export but still have older bindings. - this.clearBindings(previousBindings); - this.applyBakeResult(output, textures); - (scene.globals as any).bakedWithHighpLightmap = settings.highp; - (scene.globals as any).bakedWithStationaryMainLight = output.stationaryMainLight; - await Service.Engine.repaintInEditMode(); - await finishSavedLightFXRecording(Service.Undo, undo, - options.saveScene !== false ? () => Service.Editor.save({}) : undefined); - replacement.commit(); - } catch (error) { - if (error instanceof LightFXResultRetainedError) throw error; - this.restoreBindings(affectedBindings); - (scene.globals as any).bakedWithHighpLightmap = previousHighp; - (scene.globals as any).bakedWithStationaryMainLight = previousStationary; - Service.Undo.cancelRecording(undo); - throw error; - } finally { - replacement.cancel(); - } - - // Never put deletion in the apply rollback scope. The scene may already be on disk. - // Explicitly unsaved bakes retain pixels still needed by the saved scene, not for Undo. - if (options.saveScene !== false) { - await this.cleanupPreviousBake(scene, previousTextureUuids, textures); + const completed = output; + return await context.run(async save => { + const output = completed; + // No scene/history/disk reference may precede the host's decision to retain assets. + // An unconfirmed commit can leave an orphan version, never a dangling scene binding. + await lightFXCoordinator.commit(output.operationId); + nativeCommitted = true; + const previousBindings = this.snapshotSceneBindings(scene); + const previousTextureUuids = [...new Set([...owned, ...previousBindings.map(binding => binding.texture?.uuid ?? (binding.texture as any)?._uuid)] + .filter((uuid): uuid is string => typeof uuid === 'string' && uuid.length > 0) + .map(uuid => this.rootAssetUuid(uuid)))]; + const affectedBindings = [...previousBindings, ...this.snapshotBindings(output)]; + const previousHighp = (scene.globals as any).bakedWithHighpLightmap; + const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; + // Scene recordings do not recursively capture child components. + // Keep the flags last, after restoring each affected result binding. + const targets = [...new Set([...output.models, ...output.terrains, ...previousBindings.map(binding => binding.target)] + .map(component => component.uuid)), scene.uuid]; + const undo = Service.Undo.beginRecording(targets, { label: 'Bake lightmap' }); + const replacement = deletedLightmapAssets.beginReplacement(scene); try { - output.textureUrls = (await lightFXCoordinator.publishLightmapAssets(output.operationId)).textureUrls; + // A successful bake replaces the complete result, including disabled objects + // that were excluded from this export but still have older bindings. + this.clearBindings(previousBindings); + this.applyBakeResult(output, textures); + (scene.globals as any).bakedWithHighpLightmap = settings.highp; + (scene.globals as any).bakedWithStationaryMainLight = output.stationaryMainLight; + await Service.Engine.repaintInEditMode(); + await finishSavedLightFXRecording(Service.Undo, undo, + options.saveScene !== false ? save : undefined); + replacement.commit(); } catch (error) { - throw new Error(`New Lightmap result is saved and retained; fixed output publication was not completed. ${this.errorMessage(error)}`); + if (error instanceof LightFXResultRetainedError) throw error; + this.restoreBindings(affectedBindings); + (scene.globals as any).bakedWithHighpLightmap = previousHighp; + (scene.globals as any).bakedWithStationaryMainLight = previousStationary; + Service.Undo.cancelRecording(undo); + throw error; + } finally { + replacement.cancel(); } - } - this.broadcast('lightfx:bake-end', 'lightmap'); - return { - sceneUrl, - textureUrls: output.textureUrls, - meshCount: output.result.meshes.length, - terrainCount: output.result.terrains.length, - durationMs: Date.now() - started, - diagnostics: await lightFXCoordinator.queryDiagnostics?.('lightmap'), - }; + // Never put deletion in the apply rollback scope. The scene may already be on disk. + // Explicitly unsaved bakes retain pixels still needed by the saved scene, not for Undo. + if (options.saveScene !== false) { + await this.cleanupPreviousBake(scene, previousTextureUuids, textures); + try { + output.textureUrls = (await lightFXCoordinator.publishLightmapAssets(output.operationId)).textureUrls; + } catch (error) { + throw new Error(`New Lightmap result is saved and retained; fixed output publication was not completed. ${this.errorMessage(error)}`); + } + } + + this.broadcast('lightfx:bake-end', 'lightmap'); + return { + sceneUrl, + textureUrls: output.textureUrls, + meshCount: output.result.meshes.length, + terrainCount: output.result.terrains.length, + durationMs: Date.now() - started, + diagnostics: await lightFXCoordinator.queryDiagnostics?.('lightmap'), + }; + }); } catch (error) { if (output && !nativeCommitted) await lightFXCoordinator.rollback(output.operationId).catch((rollbackError) => { console.error('[LightFX] Failed to roll back lightmap assets:', rollbackError); @@ -218,78 +225,80 @@ export class LightmapBakeService extends BaseService impleme private async clearBakeExclusive(options: { saveScene?: boolean; deleteAssets?: boolean }): Promise { const scene = director.getScene() as Scene | null; if (!scene) throw new Error('No scene is currently open.'); - if (options.deleteAssets === true && options.saveScene === false) { - throw new Error('deleteAssets requires saveScene so the saved scene cannot retain deleted lightmap references.'); - } - if (options.deleteAssets === true) { - const capabilities = await lightFXBakeHost.queryCapabilities(); - if (capabilities?.lightmapAssetCleanupVersion !== 1 || capabilities.lightmapAuxiliaryAssetsVersion !== 1) { - throw new Error('The LightFX host does not support exact Lightmap asset cleanup. Restart the Cocos host after updating the CLI.'); + return captureLightFXScene(scene).run(async save => { + if (options.deleteAssets === true && options.saveScene === false) { + throw new Error('deleteAssets requires saveScene so the saved scene cannot retain deleted lightmap references.'); } - } - - // Query before recording/clearing so a damaged ownership record cannot partially Clear. - // Older hosts ignore sceneUuid and omit the optional list, retaining exact-bound cleanup. - const owned = options.deleteAssets === true - ? (await lightFXBakeHost.queryLightmapTextureInfo({ uuids: [], sceneUuid: scene.uuid })).ownedTextureUuids ?? [] - : []; - const bindings = this.snapshotSceneBindings(scene); - const textureUuids = [...new Set([...owned, ...bindings.map(binding => binding.texture?.uuid ?? (binding.texture as any)?._uuid)] - .filter((uuid): uuid is string => typeof uuid === 'string' && uuid.length > 0) - .map(uuid => this.rootAssetUuid(uuid)))]; - const previousHighp = (scene.globals as any).bakedWithHighpLightmap; - const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; - const targets = [...new Set(bindings.map(binding => binding.target.uuid as string)), scene.uuid]; - const undo = Service.Undo.beginRecording(targets, { label: 'Clear lightmap' }); - let retainedSceneTextureUuids = new Set(); - try { - this.clearBindings(bindings); - (scene.globals as any).bakedWithHighpLightmap = false; - (scene.globals as any).bakedWithStationaryMainLight = false; - await Service.Engine.repaintInEditMode(); if (options.deleteAssets === true) { - retainedSceneTextureUuids = await this.queryRemainingSceneTextureUuids(textureUuids); - try { - await Service.Editor.save({}); - } catch (error) { + const capabilities = await lightFXBakeHost.queryCapabilities(); + if (capabilities?.lightmapAssetCleanupVersion !== 1 || capabilities.lightmapAuxiliaryAssetsVersion !== 1) { + throw new Error('The LightFX host does not support exact Lightmap asset cleanup. Restart the Cocos host after updating the CLI.'); + } + } + + // Query before recording/clearing so a damaged ownership record cannot partially Clear. + // Older hosts ignore sceneUuid and omit the optional list, retaining exact-bound cleanup. + const owned = options.deleteAssets === true + ? (await lightFXBakeHost.queryLightmapTextureInfo({ uuids: [], sceneUuid: scene.uuid })).ownedTextureUuids ?? [] + : []; + const bindings = this.snapshotSceneBindings(scene); + const textureUuids = [...new Set([...owned, ...bindings.map(binding => binding.texture?.uuid ?? (binding.texture as any)?._uuid)] + .filter((uuid): uuid is string => typeof uuid === 'string' && uuid.length > 0) + .map(uuid => this.rootAssetUuid(uuid)))]; + const previousHighp = (scene.globals as any).bakedWithHighpLightmap; + const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; + const targets = [...new Set(bindings.map(binding => binding.target.uuid as string)), scene.uuid]; + const undo = Service.Undo.beginRecording(targets, { label: 'Clear lightmap' }); + let retainedSceneTextureUuids = new Set(); + try { + this.clearBindings(bindings); + (scene.globals as any).bakedWithHighpLightmap = false; + (scene.globals as any).bakedWithStationaryMainLight = false; + await Service.Engine.repaintInEditMode(); + if (options.deleteAssets === true) { + retainedSceneTextureUuids = await this.queryRemainingSceneTextureUuids(textureUuids); try { - await Service.Undo.endRecording(undo); - } catch (recordingError) { - throw new LightFXResultRetainedError('recording', recordingError); + await save(); + } catch (error) { + try { + await Service.Undo.endRecording(undo); + } catch (recordingError) { + throw new LightFXResultRetainedError('recording', recordingError); + } + throw new LightFXResultRetainedError('save', error); } - throw new LightFXResultRetainedError('save', error); + } else { + await finishSavedLightFXRecording(Service.Undo, undo, + options.saveScene !== false ? save : undefined); } - } else { - await finishSavedLightFXRecording(Service.Undo, undo, - options.saveScene !== false ? () => Service.Editor.save({}) : undefined); + } catch (error) { + if (error instanceof LightFXResultRetainedError) throw error; + Service.Undo.cancelRecording(undo); + this.restoreBindings(bindings); + (scene.globals as any).bakedWithHighpLightmap = previousHighp; + (scene.globals as any).bakedWithStationaryMainLight = previousStationary; + await Service.Engine.repaintInEditMode(); + throw error; } - } catch (error) { - if (error instanceof LightFXResultRetainedError) throw error; - Service.Undo.cancelRecording(undo); - this.restoreBindings(bindings); - (scene.globals as any).bakedWithHighpLightmap = previousHighp; - (scene.globals as any).bakedWithStationaryMainLight = previousStationary; - await Service.Engine.repaintInEditMode(); - throw error; - } - if (options.deleteAssets === true) { - // Keep earlier edits, but invalidate all pre-Clear baked results. This stays outside - // rollback: a notification failure must not restore only the already-saved memory state. - deletedLightmapAssets.clearResults(scene); - Service.Undo.cancelRecording(undo); - const deletableTextureUuids = textureUuids.filter(uuid => !retainedSceneTextureUuids.has(uuid)); - const finishDeletion = deletedLightmapAssets.begin(scene, deletableTextureUuids); - const result = await lightFXCoordinator.removeLightmapAssets(scene.uuid, deletableTextureUuids); - finishDeletion(result.deletedTextureUuids); - return { - clearedCount: bindings.length, - deletedAssetCount: result.deletedTextureUuids.length + (result.deletedAuxiliaryAssetUuids?.length ?? 0), - retainedAssetCount: retainedSceneTextureUuids.size + result.retainedTextureUuids.length + (result.retainedAuxiliaryAssetUuids?.length ?? 0), - failedAssetCount: result.failures.length, - }; - } - return { clearedCount: bindings.length, deletedAssetCount: 0, retainedAssetCount: 0, failedAssetCount: 0 }; + if (options.deleteAssets === true) { + // Keep earlier edits, but invalidate all pre-Clear baked results. This stays outside + // rollback: a notification failure must not restore only the already-saved memory state. + deletedLightmapAssets.clearResults(scene); + Service.Undo.cancelRecording(undo); + const deletableTextureUuids = textureUuids.filter(uuid => !retainedSceneTextureUuids.has(uuid)); + const finishDeletion = deletedLightmapAssets.begin(scene, deletableTextureUuids); + const result = await lightFXCoordinator.removeLightmapAssets(scene.uuid, deletableTextureUuids); + finishDeletion(result.deletedTextureUuids); + return { + clearedCount: bindings.length, + deletedAssetCount: result.deletedTextureUuids.length + (result.deletedAuxiliaryAssetUuids?.length ?? 0), + retainedAssetCount: retainedSceneTextureUuids.size + result.retainedTextureUuids.length + (result.retainedAuxiliaryAssetUuids?.length ?? 0), + failedAssetCount: result.failures.length, + }; + } + return { clearedCount: bindings.length, deletedAssetCount: 0, retainedAssetCount: 0, failedAssetCount: 0 }; + }); } /** Returns candidates still referenced by the live scene after its Lightmap bindings are cleared. */ diff --git a/src/core/scene/scene-process/service/scene/light-probe-transform.ts b/src/core/scene/scene-process/service/scene/light-probe-transform.ts index 023a1a76f..4d171e839 100644 --- a/src/core/scene/scene-process/service/scene/light-probe-transform.ts +++ b/src/core/scene/scene-process/service/scene/light-probe-transform.ts @@ -3,7 +3,8 @@ import { Vec3, type Node, type Scene } from 'cc'; /** Finds the scene whose registered probe positions can be affected by this subtree. */ export function getLightProbeTransformScene(node: Node): Scene | undefined { const scene = node.scene; - if (!node.isValid || !scene?.globals?.lightProbeInfo) return; + // Ordinary scenes do not need a subtree scan on every transform/Undo capture. + if (!node.isValid || !scene?.globals?.lightProbeInfo?.data?.probes?.length) return; const groups = node.getComponentsInChildren('cc.LightProbeGroup'); return groups.some(group => group.isValid && group.enabledInHierarchy) ? scene : undefined; } diff --git a/src/core/scene/test/editor-save-as.test.ts b/src/core/scene/test/editor-save-as.test.ts index 84e05e1b7..2c4194cb1 100644 --- a/src/core/scene/test/editor-save-as.test.ts +++ b/src/core/scene/test/editor-save-as.test.ts @@ -51,6 +51,56 @@ describe('EditorService Save As', () => { globalEventEmitter.removeAllListeners(); }); + it.each([false, true])('rejects an obsolete result session inside the lifecycle queue (same UUID=%s)', async sameUuid => { + editorService.currentEditorUuid = 'source'; + editorService.isOpen = true; + const session = editorService.getEditorSession(); + const change = editorService.runLifecycle(async () => { + editorService.invalidateEditorSession(); + editorService.currentEditorUuid = sameUuid ? 'source' : 'replacement'; + }); + const apply = jest.fn(); + const result = editorService.runForSession(session, apply); + await change; + await expect(result).rejects.toThrow('source scene session changed'); + expect(apply).not.toHaveBeenCalled(); + }); + + it('holds the original session through result save and cleanup without re-entering the lifecycle queue', async () => { + editorService.currentEditorUuid = 'source'; + editorService.isOpen = true; + const session = editorService.getEditorSession(); + const events: string[] = []; + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + const saveUnlocked = jest.spyOn(editorService, 'saveUnlocked').mockImplementation(async (...args: unknown[]) => { + expect(args[0]).toEqual({ urlOrUUID: 'source' }); + expect(editorService.currentEditorUuid).toBe('source'); + events.push('save'); + }); + const apply = editorService.runForSession(session, async (save: () => Promise) => { + events.push('apply'); + entered(); + await gate; + await save(); + events.push('cleanup'); + }); + await started; + const change = editorService.runLifecycle(async () => { + editorService.invalidateEditorSession(); + editorService.currentEditorUuid = 'replacement'; + events.push('switch'); + }); + await Promise.resolve(); + expect(events).toEqual(['apply']); + release(); + await Promise.all([apply, change]); + expect(events).toEqual(['apply', 'save', 'cleanup', 'switch']); + expect(saveUnlocked).toHaveBeenCalledTimes(1); + }); + it('requires Save As for a target other than the existing source asset', async () => { const sourceUuid = 'source-uuid'; const target = { uuid: 'target-uuid', url: 'db://assets/copied.scene', type: 'scene' }; diff --git a/src/core/scene/test/light-probe-reparent.test.ts b/src/core/scene/test/light-probe-reparent.test.ts index 1eafcd311..0ee2543a9 100644 --- a/src/core/scene/test/light-probe-reparent.test.ts +++ b/src/core/scene/test/light-probe-reparent.test.ts @@ -47,7 +47,7 @@ describe('Probe globals in reparent history', () => { it('captures one affected scene and restores it after parent and node data without reparenting the root', async () => { const scene = node('scene'); scene.scene = scene; - scene.globals = { lightProbeInfo: {} }; + scene.globals = { lightProbeInfo: { data: { probes: [{}] } } }; const oldParent = node('old', scene); const newParent = node('new', scene); const group = node('group', scene); diff --git a/src/core/scene/test/light-probe-transform.test.ts b/src/core/scene/test/light-probe-transform.test.ts index f35e526ea..25fdff870 100644 --- a/src/core/scene/test/light-probe-transform.test.ts +++ b/src/core/scene/test/light-probe-transform.test.ts @@ -33,6 +33,15 @@ function fixture() { } describe('Light probe position synchronization', () => { + it.each([null, { probes: [] }])('does not scan ordinary scene subtrees without generated probes (%s)', data => { + const { node, scene, info } = fixture(); + (scene.globals.lightProbeInfo as any).data = data; + const scan = jest.spyOn(node, 'getComponentsInChildren'); + synchronizeLightProbeTransform(node); + expect(withLightProbeTransformScenes([node])).toEqual([node]); + expect(scan).not.toHaveBeenCalled(); + expect(info.update).not.toHaveBeenCalled(); + }); it('retains moved and stationary groups coefficients when translating a group', () => { const { node, nextPositions, info, events } = fixture(); // First two samples belong to A, last two to the stationary group B. diff --git a/src/core/scene/test/lightfx-asset-versions.test.ts b/src/core/scene/test/lightfx-asset-versions.test.ts index 2a39c1a0c..3e816093b 100644 --- a/src/core/scene/test/lightfx-asset-versions.test.ts +++ b/src/core/scene/test/lightfx-asset-versions.test.ts @@ -9,7 +9,8 @@ const mockAssets = { queryAssetInfo: jest.fn(), queryAssetUsers: jest.fn(), removeAsset: jest.fn(), moveAsset: jest.fn(), }; const mockRun = jest.fn(); -jest.mock('../../assets', () => ({ assetManager: mockAssets })); +const mockAssetDB = { ready: true, isBusy: jest.fn(() => false) }; +jest.mock('../../assets', () => ({ assetManager: mockAssets, assetDBManager: mockAssetDB })); jest.mock('../main-process/lightfx/process', () => ({ LightFXProcess: jest.fn(() => ({ run: mockRun, cancel: async () => undefined })) })); jest.mock('../main-process/lightfx/output', () => ({ decodeLightFXOutput: () => ({ version: 1, meshes: [], terrains: [], probes: [] }) })); import { LightFXBakeHost } from '../main-process/lightfx-bake-host'; @@ -30,6 +31,8 @@ describe('Immutable Lightmap asset versions', () => { mockAssets.queryUUID.mockReset().mockImplementation((url: string) => url); mockRun.mockReset(); host = new LightFXBakeHost(); + mockAssetDB.ready = true; + mockAssetDB.isBusy.mockReturnValue(false); }); afterEach(async () => { await host.dispose(); await remove(root); }); @@ -86,7 +89,7 @@ describe('Immutable Lightmap asset versions', () => { await host.commit(a.token); await expect(host.publishLightmapAssets({ ...request, operationId: randomUUID() })).rejects.toThrow('ownership'); const output = await host.publishLightmapAssets(request); - const target = `${outputUrl && outputUrl !== 'db://assets' ? outputUrl : 'db://assets/LightFX'}/output/LFX_Mesh_0000.png`; + const target = `${outputUrl && outputUrl !== 'db://assets' ? outputUrl : 'db://assets/LightFX'}/scene-${sceneUuid}/output/LFX_Mesh_0000.png`; expect(output.textureUrls).toEqual([target]); expect([identities.get(uuid)?.url, await readFile(assetPath(target), 'utf8'), await pathExists(a.path)]).toEqual([target, 'pixels A', false]); const auxRoot = target.slice(0, -'/output/LFX_Mesh_0000.png'.length); @@ -140,10 +143,10 @@ describe('Immutable Lightmap asset versions', () => { .toEqual([[oldIds[0]], oldIds.slice(1), []]); expect(await pathExists(b.path)).toBe(true); await host.publishLightmapAssets({ ...b.token, ...second }); - expect(await readFile(assetPath('db://assets/LightFX/lfx.log'), 'utf8')).toBe('native B'); + expect(await readFile(assetPath(`db://assets/LightFX/scene-${sceneUuid}/lfx.log`), 'utf8')).toBe('native B'); expect([...identities.values()].map(info => info.url)).toEqual([ - 'db://assets/LightFX/output/LFX_Mesh_0000.png', 'db://assets/LightFX/tmp/lfx.in', - 'db://assets/LightFX/output/lfx.out', 'db://assets/LightFX/lfx.log', + `db://assets/LightFX/scene-${sceneUuid}/output/LFX_Mesh_0000.png`, `db://assets/LightFX/scene-${sceneUuid}/tmp/lfx.in`, + `db://assets/LightFX/scene-${sceneUuid}/output/lfx.out`, `db://assets/LightFX/scene-${sceneUuid}/lfx.log`, ]); await host.releaseSceneOperation(second); }); @@ -164,6 +167,89 @@ describe('Immutable Lightmap asset versions', () => { expect((await host.removeLightmapAssets({ sceneUuid, textureUuids: [] })).deletedAuxiliaryAssetUuids).toEqual(auxiliary); expect(identities.size).toBe(0); }); + it.each([undefined, 'db://assets', 'db://assets/Shared'])('isolates two same-name scenes, repeated bake and Clear in %s', async outputUrl => { + const identities = realAssetFiles(); + if (outputUrl) await ensureDir(assetPath(outputUrl)); + const scenes = [randomUUID(), randomUUID()]; + const published: string[] = []; + for (const sceneUuid of scenes) { + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const a = await bake(sceneUuid, outputUrl, sceneUuid, owner.transactionId); + await host.commit(a.token); + published.push((await host.publishLightmapAssets({ ...a.token, ...owner })).textureUrls[0]); + await host.releaseSceneOperation(owner); + } + expect(published[0]).not.toBe(published[1]); + const oldIds = await new LightmapAssetRecord(root, scenes[0]).read(); + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const next = await bake('replacement', outputUrl, scenes[0], owner.transactionId); + await host.commit(next.token); + await host.removeLightmapAssets({ ...owner, sceneUuid: scenes[0], textureUuids: oldIds, action: 'bake' }); + expect((await host.publishLightmapAssets({ ...owner, ...next.token })).textureUrls[0]).toBe(published[0]); + await host.releaseSceneOperation(owner); + await host.removeLightmapAssets({ sceneUuid: scenes[0], textureUuids: await new LightmapAssetRecord(root, scenes[0]).read() }); + expect(await pathExists(assetPath(published[0]))).toBe(false); + expect(await readFile(assetPath(published[1]), 'utf8')).toBe(scenes[1]); + expect([...identities.values()].filter(info => info.url.includes(scenes[1]))).toHaveLength(4); + }); + it('forgets auxiliary membership already removed through Asset DB, including retries after restart', async () => { + const identities = realAssetFiles(), sceneUuid = randomUUID(); + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const a = await bake('A', undefined, sceneUuid, owner.transactionId); + await host.commit(a.token); + await host.publishLightmapAssets({ ...a.token, ...owner }); + await host.releaseSceneOperation(owner); + const log = [...identities.values()].find(info => info.url.endsWith('/lfx.log'))!; + await mockAssets.removeAsset(log.uuid); + const png = [...identities.values()].find(info => info.url.endsWith('.png'))!; + expect((await host.removeLightmapAssets({ sceneUuid, textureUuids: [png.uuid] })).failures).toEqual([]); + host = new LightFXBakeHost(); + expect((await host.removeLightmapAssets({ sceneUuid, textureUuids: [] })).failures).toEqual([]); + expect(await new LightmapAssetRecord(root, sceneUuid).readAuxiliary()).toEqual([]); + const next = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const b = await bake('B', undefined, sceneUuid, next.transactionId); + await host.commit(b.token); + await expect(host.publishLightmapAssets({ ...b.token, ...next })).resolves.toHaveProperty('textureUrls'); + await host.releaseSceneOperation(next); + }); + it('cleans recorded legacy flat output before publishing into the scene-specific directory', async () => { + const identities = realAssetFiles(), sceneUuid = randomUUID(); + const first = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const a = await bake('legacy', undefined, sceneUuid, first.transactionId); + await host.commit(a.token); + await host.publishLightmapAssets({ ...a.token, ...first }); + await host.releaseSceneOperation(first); + const oldPaths: string[] = []; + for (const info of [...identities.values()]) { + const legacy = info.url.replace(`/scene-${sceneUuid}`, ''); + await mockAssets.moveAsset(info.url, legacy); + oldPaths.push(assetPath(legacy)); + } + const old = await new LightmapAssetRecord(root, sceneUuid).read(); + const next = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const b = await bake('new', undefined, sceneUuid, next.transactionId); + await host.commit(b.token); + expect((await host.removeLightmapAssets({ ...next, sceneUuid, textureUuids: old, action: 'bake' })).failures).toEqual([]); + const result = await host.publishLightmapAssets({ ...b.token, ...next }); + expect(result.textureUrls[0]).toContain(`/scene-${sceneUuid}/output/`); + expect(await Promise.all(oldPaths.map(file => pathExists(file)))).toEqual([false, false, false, false]); + await host.releaseSceneOperation(next); + }); + it.each(['startup', 'busy', 'query-error'])('retains missing membership during %s', async state => { + realAssetFiles(); + const sceneUuid = randomUUID(), missing = randomUUID(); + const record = new LightmapAssetRecord(root, sceneUuid); + await record.add([], [missing]); + if (state === 'startup') mockAssetDB.ready = false; + if (state === 'busy') mockAssetDB.isBusy.mockReturnValue(true); + if (state === 'query-error') { + mockAssets.queryAssetInfo.mockImplementation(() => { throw new Error('database unavailable'); }); + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [] })).rejects.toThrow('database unavailable'); + } else { + expect((await host.removeLightmapAssets({ sceneUuid, textureUuids: [] })).failures).toHaveLength(1); + } + expect(await record.readAuxiliary()).toEqual([missing]); + }); it('rolls back only newly staged native products without altering a previous fixed result', async () => { const identities = realAssetFiles(), sceneUuid = randomUUID(); @@ -177,7 +263,7 @@ describe('Immutable Lightmap asset versions', () => { const b = await bake('B', undefined, sceneUuid, next.transactionId); await host.rollback(b.token); expect(await new LightmapAssetRecord(root, sceneUuid).readAuxiliary()).toEqual(oldAuxiliary); - expect(await readFile(assetPath('db://assets/LightFX/lfx.log'), 'utf8')).toBe('native A'); + expect(await readFile(assetPath(`db://assets/LightFX/scene-${sceneUuid}/lfx.log`), 'utf8')).toBe('native A'); expect(await pathExists(b.path)).toBe(false); await host.releaseSceneOperation(next); expect([...identities.values()].filter(info => existsSync(info.file))).toHaveLength(4); diff --git a/src/core/scene/test/lightfx-result-failures.test.ts b/src/core/scene/test/lightfx-result-failures.test.ts index 5e9426c75..a5f9ef746 100644 --- a/src/core/scene/test/lightfx-result-failures.test.ts +++ b/src/core/scene/test/lightfx-result-failures.test.ts @@ -16,7 +16,12 @@ jest.mock('cc', () => ({ director: { getScene: mockGetScene }, MeshRenderer: moc Vec3: MockVec3, SH: { getBasisCount: () => 9 } })); jest.mock('../scene-process/service/core', () => ({ BaseService: class { broadcast() {} }, register: () => () => undefined, - Service: { Undo: mockUndo, Editor: { save: mockSave, querySceneSerializedData: mockQuerySceneSerializedData }, Engine: { repaintInEditMode: mockRepaint } }, + Service: { Undo: mockUndo, Editor: { + save: mockSave, querySceneSerializedData: mockQuerySceneSerializedData, + getEditorSession: () => ({ uuid: mockGetScene()?.uuid, generation: 0 }), + isCurrentEditorSession: (session: any) => session.uuid === mockGetScene()?.uuid, + runForSession: async (_session: any, action: any) => action(mockSave), + }, Engine: { repaintInEditMode: mockRepaint } }, })); jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { bake: mockBake, commit: mockCommit, rollback: mockRollback, removeLightmapAssets: mockRemoveLightmapAssets, @@ -108,6 +113,23 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t await f.bake(); expect({ events: f.events, disk: f.disk(), dirty: f.manager.isDirty() }).toEqual({ events: ['commit', 'record', 'save'], disk: f.read(), dirty: false }); }); + it.each([false, true])('rejects a replaced scene before committing, saving or deleting (same UUID=%s)', sameUuid => { + const f = fixture(target); + const source = mockGetScene(); + const original = mockBake.getMockImplementation()!; + mockBake.mockImplementationOnce(async (...args) => { + const output = await original(...args); + mockGetScene.mockReturnValue({ ...source, uuid: sameUuid ? source.uuid : 'other-scene' }); + return output; + }); + return expect(f.bake()).rejects.toThrow('source scene changed').then(() => { + expect(mockSave).not.toHaveBeenCalled(); + expect(mockCommit).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + expect(f.read()).toEqual(f.old); + }); + }); it.each([false, true])('keeps ordinary edits and only current Lightmap results in edit → rebake → clear history (save=%s)', async saveScene => { const f = fixture(target); diff --git a/src/core/scene/test/lightmap-result-recording.test.ts b/src/core/scene/test/lightmap-result-recording.test.ts index d6fe079d5..54f559e57 100644 --- a/src/core/scene/test/lightmap-result-recording.test.ts +++ b/src/core/scene/test/lightmap-result-recording.test.ts @@ -21,7 +21,12 @@ const mockQueryTextureInfo = jest.fn(async (): Promise<{ textures: []; missingTe jest.mock('cc', () => ({ director: { getScene: mockGetScene }, MeshRenderer: mockMeshRenderer, Terrain: mockTerrain })); jest.mock('../scene-process/service/core', () => ({ BaseService: class { broadcast() {} }, register: () => () => undefined, - Service: { Undo: mockUndo, Editor: { save: mockSave, querySceneSerializedData: mockQuerySceneSerializedData }, Engine: { repaintInEditMode: async () => undefined } }, + Service: { Undo: mockUndo, Editor: { + save: mockSave, querySceneSerializedData: mockQuerySceneSerializedData, + getEditorSession: () => ({ uuid: mockGetScene()?.uuid, generation: 0 }), + isCurrentEditorSession: (session: any) => session.uuid === mockGetScene()?.uuid, + runForSession: async (_session: any, action: any) => action(mockSave), + }, Engine: { repaintInEditMode: async () => undefined } }, })); jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { bake: mockBake, commit: mockCommit, rollback: mockRollback, removeLightmapAssets: mockRemoveLightmapAssets, publishLightmapAssets: mockPublishLightmapAssets } })); jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { From 561d15fdb7f9ebf9772f5716c9e087cdd93f2927 Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Sat, 12 Sep 2026 14:16:39 +0800 Subject: [PATCH 61/64] test(lightfx): consolidate related bake test suites --- .../scene/test/light-probe-metadata.test.ts | 32 ----------- ...st.ts => lightfx-asset-management.test.ts} | 49 ++++++++++++++++- .../test/lightfx-asset-transaction.test.ts | 50 ----------------- ...htfx-format.test.ts => lightfx-io.test.ts} | 24 +++++++++ src/core/scene/test/lightfx-metadata.test.ts | 53 +++++++++++++++++++ .../scene/test/lightfx-scene-stats.test.ts | 15 ------ src/core/scene/test/lightmap-metadata.test.ts | 19 ------- src/core/scene/test/lightmap-uv.test.ts | 10 ---- 8 files changed, 125 insertions(+), 127 deletions(-) delete mode 100644 src/core/scene/test/light-probe-metadata.test.ts rename src/core/scene/test/{lightfx-asset-record.test.ts => lightfx-asset-management.test.ts} (53%) delete mode 100644 src/core/scene/test/lightfx-asset-transaction.test.ts rename src/core/scene/test/{lightfx-format.test.ts => lightfx-io.test.ts} (73%) create mode 100644 src/core/scene/test/lightfx-metadata.test.ts delete mode 100644 src/core/scene/test/lightfx-scene-stats.test.ts delete mode 100644 src/core/scene/test/lightmap-metadata.test.ts delete mode 100644 src/core/scene/test/lightmap-uv.test.ts diff --git a/src/core/scene/test/light-probe-metadata.test.ts b/src/core/scene/test/light-probe-metadata.test.ts deleted file mode 100644 index 9ef7da4e9..000000000 --- a/src/core/scene/test/light-probe-metadata.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -jest.mock('cc', () => ({ - Vec3: class Vec3 {}, - js: { getClassName: (object: object) => object.constructor.name === 'Vertex' ? 'cc.Vertex' : 'cc.Other' }, -})); - -import { Vec3 } from 'cc'; -import { withLightProbeCoefficientType } from '../scene-process/service/dump/light-probe-metadata'; - -class Vertex { coefficients: Vec3[] = []; } - -describe('Light probe dump metadata', () => { - it('supplies Vec3 for legacy SH arrays without mutating engine attributes', () => { - const attributes = Object.freeze({ default: () => [], serializable: true, visible: false }); - const owner = new Vertex(); - expect(withLightProbeCoefficientType(attributes, owner, 'coefficients')).toEqual({ ...attributes, ctor: Vec3 }); - expect(attributes).not.toHaveProperty('ctor'); - }); - - it('preserves an engine-provided element constructor', () => { - const attributes = { ctor: Vec3, serializable: true }; - expect(withLightProbeCoefficientType(attributes, new Vertex(), 'coefficients')).toBe(attributes); - }); - - it.each([ - [new Vertex(), 'position'], - [{ coefficients: [] }, 'coefficients'], - [null, 'coefficients'], - ])('does not change unrelated metadata (%p, %s)', (owner, key) => { - const attributes = { ctor: undefined, default: () => [] }; - expect(withLightProbeCoefficientType(attributes, owner, key)).toBe(attributes); - }); -}); diff --git a/src/core/scene/test/lightfx-asset-record.test.ts b/src/core/scene/test/lightfx-asset-management.test.ts similarity index 53% rename from src/core/scene/test/lightfx-asset-record.test.ts rename to src/core/scene/test/lightfx-asset-management.test.ts index 6e298f123..f27cc069c 100644 --- a/src/core/scene/test/lightfx-asset-record.test.ts +++ b/src/core/scene/test/lightfx-asset-management.test.ts @@ -1,7 +1,8 @@ -import { mkdtemp, outputFile, readFile, readdir, remove } from 'fs-extra'; +import { mkdtemp, outputFile, pathExists, readFile, readdir, remove } from 'fs-extra'; import { tmpdir } from 'os'; import { join } from 'path'; import { LightmapAssetRecord } from '../main-process/lightfx/asset-record'; +import { LightmapAssetTransaction } from '../main-process/lightfx/asset-transaction'; describe('Exact scene Lightmap asset membership', () => { let root: string; @@ -49,3 +50,49 @@ describe('Exact scene Lightmap asset membership', () => { expect(await readFile(file, 'utf8')).toBe(content); }); }); + +describe('LightmapAssetTransaction', () => { + let root: string; + + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'lightfx-assets-')); }); + afterEach(async () => { await remove(root); }); + + it('restores an existing lightmap directory after a failed import', async () => { + const target = join(root, 'assets', 'Scene', 'lightmap'); + await outputFile(join(target, 'old.png'), 'old'); + await outputFile(join(target, 'old.png.meta'), 'meta'); + const transaction = new LightmapAssetTransaction(target, join(root, 'workspace')); + await transaction.prepare(); + expect(await pathExists(join(target, 'old.png'))).toBe(false); + await transaction.preserveMeta('old.png'); + expect((await readFile(join(target, 'old.png.meta'))).toString()).toBe('meta'); + await outputFile(join(target, 'new.png'), 'new'); + await transaction.rollback(); + expect((await readFile(join(target, 'old.png'))).toString()).toBe('old'); + expect(await pathExists(join(target, 'new.png'))).toBe(false); + }); + + it('removes a newly created lightmap directory after rollback', async () => { + const target = join(root, 'assets', 'Scene', 'lightmap'); + const transaction = new LightmapAssetTransaction(target, join(root, 'workspace')); + await transaction.prepare(); + await outputFile(join(target, 'new.png'), 'new'); + await transaction.rollback(); + expect(await pathExists(target)).toBe(false); + }); + + it('keeps rollback retryable when restoring the backup fails', async () => { + const target = join(root, 'assets', 'Scene', 'lightmap'); + await outputFile(join(target, 'old.png'), 'old'); + const workspace = join(root, 'workspace'); + const backup = join(workspace, 'lightmap-asset-backup'); + const transaction = new LightmapAssetTransaction(target, workspace); + await transaction.prepare(); + await remove(backup); + + await expect(transaction.rollback()).rejects.toThrow(); + await outputFile(join(backup, 'old.png'), 'old'); + await expect(transaction.rollback()).resolves.toBeUndefined(); + await expect(readFile(join(target, 'old.png'), 'utf8')).resolves.toBe('old'); + }); +}); diff --git a/src/core/scene/test/lightfx-asset-transaction.test.ts b/src/core/scene/test/lightfx-asset-transaction.test.ts deleted file mode 100644 index cea19d779..000000000 --- a/src/core/scene/test/lightfx-asset-transaction.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { mkdtemp, outputFile, pathExists, readFile, remove } from 'fs-extra'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { LightmapAssetTransaction } from '../main-process/lightfx/asset-transaction'; - -describe('LightmapAssetTransaction', () => { - let root: string; - - beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'lightfx-assets-')); }); - afterEach(async () => { await remove(root); }); - - it('restores an existing lightmap directory after a failed import', async () => { - const target = join(root, 'assets', 'Scene', 'lightmap'); - await outputFile(join(target, 'old.png'), 'old'); - await outputFile(join(target, 'old.png.meta'), 'meta'); - const transaction = new LightmapAssetTransaction(target, join(root, 'workspace')); - await transaction.prepare(); - expect(await pathExists(join(target, 'old.png'))).toBe(false); - await transaction.preserveMeta('old.png'); - expect((await readFile(join(target, 'old.png.meta'))).toString()).toBe('meta'); - await outputFile(join(target, 'new.png'), 'new'); - await transaction.rollback(); - expect((await readFile(join(target, 'old.png'))).toString()).toBe('old'); - expect(await pathExists(join(target, 'new.png'))).toBe(false); - }); - - it('removes a newly created lightmap directory after rollback', async () => { - const target = join(root, 'assets', 'Scene', 'lightmap'); - const transaction = new LightmapAssetTransaction(target, join(root, 'workspace')); - await transaction.prepare(); - await outputFile(join(target, 'new.png'), 'new'); - await transaction.rollback(); - expect(await pathExists(target)).toBe(false); - }); - - it('keeps rollback retryable when restoring the backup fails', async () => { - const target = join(root, 'assets', 'Scene', 'lightmap'); - await outputFile(join(target, 'old.png'), 'old'); - const workspace = join(root, 'workspace'); - const backup = join(workspace, 'lightmap-asset-backup'); - const transaction = new LightmapAssetTransaction(target, workspace); - await transaction.prepare(); - await remove(backup); - - await expect(transaction.rollback()).rejects.toThrow(); - await outputFile(join(backup, 'old.png'), 'old'); - await expect(transaction.rollback()).resolves.toBeUndefined(); - await expect(readFile(join(target, 'old.png'), 'utf8')).resolves.toBe('old'); - }); -}); diff --git a/src/core/scene/test/lightfx-format.test.ts b/src/core/scene/test/lightfx-io.test.ts similarity index 73% rename from src/core/scene/test/lightfx-format.test.ts rename to src/core/scene/test/lightfx-io.test.ts index ed76838ea..5a7c2fd40 100644 --- a/src/core/scene/test/lightfx-format.test.ts +++ b/src/core/scene/test/lightfx-io.test.ts @@ -4,6 +4,8 @@ import { LIGHTFX_FILE_VERSION, LightFXChunk, LightFXWorld } from '../scene-proce import { createDefaultLightFXSettings } from '../scene-process/service/baking/lightfx/settings'; import { decodeLightFXOutput } from '../main-process/lightfx/output'; import { MAX_LIGHTMAP_GI_SAMPLES } from '../common/lightfx-limits'; +import { validLightmapUV } from '../scene-process/service/baking/lightfx/lightmap-uv'; +import { lightmapSceneStats } from '../scene-process/service/baking/lightfx/scene-stats'; describe('LightFX binary format', () => { it('uses the last non-overflowing native Lightmap sampling factor', () => { @@ -53,3 +55,25 @@ describe('LightFX binary format', () => { const unknown = new LightFXBuffer(); unknown.writeInt32(LIGHTFX_FILE_VERSION); unknown.writeInt32(99); expect(() => decodeLightFXOutput(unknown.toUint8Array())).toThrow('Unknown'); }); }); + +describe('Lightmap export UV validation', () => { + it.each([ + [null, 3, false], [[0, 0], 3, false], [[0, NaN], 1, false], [[Infinity, 0], 1, false], + [[0, 0, 1, 0, 0, 1], 3, true], [new Float32Array([0, 1]), 1, true], [[], 0, false], + ])('validates UV1 %p for %p vertices', (uv, count, expected) => { + expect(validLightmapUV(uv as number[] | null, count as number)).toBe(expected); + }); +}); + +describe('Lightmap exported scene statistics', () => { + it('counts mesh triangles plus full terrain tiles, not packed images or terrain tasks', () => { + const world = { meshes: [{ triangles: Array(12) }], terrains: [{ blockCount: [2, 1] }], lights: [{}] } as LightFXWorld; + expect(lightmapSceneStats(world, 32)).toEqual({ objects: 2, lights: 1, triangles: 4108 }); + }); + it('counts mesh-only and empty exported worlds without inventing objects', () => { + const world = { meshes: [{ triangles: Array(12) }, { triangles: Array(200) }, { triangles: Array(12) }], terrains: [], lights: [{}] } as unknown as LightFXWorld; + expect(lightmapSceneStats(world, 32)).toEqual({ objects: 3, lights: 1, triangles: 224 }); + expect(lightmapSceneStats({ meshes: [], terrains: [], lights: [] } as unknown as LightFXWorld, 32)) + .toEqual({ objects: 0, lights: 0, triangles: 0 }); + }); +}); diff --git a/src/core/scene/test/lightfx-metadata.test.ts b/src/core/scene/test/lightfx-metadata.test.ts new file mode 100644 index 000000000..e3c9c831f --- /dev/null +++ b/src/core/scene/test/lightfx-metadata.test.ts @@ -0,0 +1,53 @@ +jest.mock('cc', () => ({ + Vec3: class Vec3 {}, + Texture2D: class Texture2D {}, + js: { + getClassName: (value: { type?: string }) => value.type + ?? (value.constructor.name === 'Vertex' ? 'cc.Vertex' : 'cc.Other'), + }, +})); + +import { Vec3, Texture2D } from 'cc'; +import { withLightProbeCoefficientType } from '../scene-process/service/dump/light-probe-metadata'; +import { withLightmapTextureType } from '../scene-process/service/dump/lightmap-metadata'; + +class Vertex { coefficients: Vec3[] = []; } + +describe('Light probe dump metadata', () => { + it('supplies Vec3 for legacy SH arrays without mutating engine attributes', () => { + const attributes = Object.freeze({ default: () => [], serializable: true, visible: false }); + const owner = new Vertex(); + expect(withLightProbeCoefficientType(attributes, owner, 'coefficients')).toEqual({ ...attributes, ctor: Vec3 }); + expect(attributes).not.toHaveProperty('ctor'); + }); + + it('preserves an engine-provided element constructor', () => { + const attributes = { ctor: Vec3, serializable: true }; + expect(withLightProbeCoefficientType(attributes, new Vertex(), 'coefficients')).toBe(attributes); + }); + + it.each([ + [new Vertex(), 'position'], + [{ coefficients: [] }, 'coefficients'], + [null, 'coefficients'], + ])('does not change unrelated metadata (%p, %s)', (owner, key) => { + const attributes = { ctor: undefined, default: () => [] }; + expect(withLightProbeCoefficientType(attributes, owner, key)).toBe(attributes); + }); +}); + +describe('Lightmap texture snapshot metadata', () => { + it.each(['cc.ModelBakeSettings', 'cc.TerrainBlockLightmapInfo'])('types even cleared texture references on %s without changing engine metadata', type => { + const attributes = Object.freeze({ default: null }); + expect(withLightmapTextureType(attributes, { type }, 'texture')).toEqual({ default: null, ctor: Texture2D }); + expect(attributes).toEqual({ default: null }); + }); + it('preserves declared constructors', () => { + const attributes = { ctor: class CustomTexture {} }; + expect(withLightmapTextureType(attributes, { type: 'cc.ModelBakeSettings' }, 'texture')).toBe(attributes); + }); + it.each([[null, 'texture'], [{ type: 'cc.Other' }, 'texture'], [{ type: 'cc.ModelBakeSettings' }, 'uvParam']])('does not change unrelated properties (%p, %s)', (owner, key) => { + const attributes = {}; + expect(withLightmapTextureType(attributes, owner as object | null, key as string)).toBe(attributes); + }); +}); diff --git a/src/core/scene/test/lightfx-scene-stats.test.ts b/src/core/scene/test/lightfx-scene-stats.test.ts deleted file mode 100644 index 6da062046..000000000 --- a/src/core/scene/test/lightfx-scene-stats.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { lightmapSceneStats } from '../scene-process/service/baking/lightfx/scene-stats'; -import type { LightFXWorld } from '../scene-process/service/baking/lightfx/types'; - -describe('Lightmap exported scene statistics', () => { - it('counts mesh triangles plus full terrain tiles, not packed images or terrain tasks', () => { - const world = { meshes: [{ triangles: Array(12) }], terrains: [{ blockCount: [2, 1] }], lights: [{}] } as LightFXWorld; - expect(lightmapSceneStats(world, 32)).toEqual({ objects: 2, lights: 1, triangles: 4108 }); - }); - it('counts mesh-only and empty exported worlds without inventing objects', () => { - const world = { meshes: [{ triangles: Array(12) }, { triangles: Array(200) }, { triangles: Array(12) }], terrains: [], lights: [{}] } as unknown as LightFXWorld; - expect(lightmapSceneStats(world, 32)).toEqual({ objects: 3, lights: 1, triangles: 224 }); - expect(lightmapSceneStats({ meshes: [], terrains: [], lights: [] } as unknown as LightFXWorld, 32)) - .toEqual({ objects: 0, lights: 0, triangles: 0 }); - }); -}); diff --git a/src/core/scene/test/lightmap-metadata.test.ts b/src/core/scene/test/lightmap-metadata.test.ts deleted file mode 100644 index 1461c3390..000000000 --- a/src/core/scene/test/lightmap-metadata.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -const mockTexture = class Texture2D {}; -jest.mock('cc', () => ({ Texture2D: mockTexture, js: { getClassName: (value: any) => value.type } })); -import { withLightmapTextureType } from '../scene-process/service/dump/lightmap-metadata'; - -describe('Lightmap texture snapshot metadata', () => { - it.each(['cc.ModelBakeSettings', 'cc.TerrainBlockLightmapInfo'])('types even cleared texture references on %s without changing engine metadata', type => { - const attributes = Object.freeze({ default: null }); - expect(withLightmapTextureType(attributes, { type }, 'texture')).toEqual({ default: null, ctor: mockTexture }); - expect(attributes).toEqual({ default: null }); - }); - it('preserves declared constructors', () => { - const attributes = { ctor: class CustomTexture {} }; - expect(withLightmapTextureType(attributes, { type: 'cc.ModelBakeSettings' }, 'texture')).toBe(attributes); - }); - it.each([[null, 'texture'], [{ type: 'cc.Other' }, 'texture'], [{ type: 'cc.ModelBakeSettings' }, 'uvParam']])('does not change unrelated properties (%p, %s)', (owner, key) => { - const attributes = {}; - expect(withLightmapTextureType(attributes, owner as object | null, key as string)).toBe(attributes); - }); -}); diff --git a/src/core/scene/test/lightmap-uv.test.ts b/src/core/scene/test/lightmap-uv.test.ts deleted file mode 100644 index c9d39ff39..000000000 --- a/src/core/scene/test/lightmap-uv.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { validLightmapUV } from '../scene-process/service/baking/lightfx/lightmap-uv'; - -describe('Lightmap export UV validation', () => { - it.each([ - [null, 3, false], [[0, 0], 3, false], [[0, NaN], 1, false], [[Infinity, 0], 1, false], - [[0, 0, 1, 0, 0, 1], 3, true], [new Float32Array([0, 1]), 1, true], [[], 0, false], - ])('validates UV1 %p for %p vertices', (uv, count, expected) => { - expect(validLightmapUV(uv as number[] | null, count as number)).toBe(expected); - }); -}); From 0d2373a09464023f864662a999094a3615d0ea9e Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Sat, 12 Sep 2026 14:56:05 +0800 Subject: [PATCH 62/64] fix(lightfx): pin scene before reservation and verify asset moves --- docs/dev/scene/lightfx-bake.md | 2 +- .../assets/test/manager-filesystem.test.ts | 37 ++++++++-- .../assets/test/move-source-failure.test.ts | 67 +++++++++++++++++-- .../test/operation-filesystem-bridge.test.ts | 28 +++++++- .../service/baking/lightfx/scene-context.ts | 19 ++++++ .../service/baking/lightfx/scene-operation.ts | 4 +- .../scene-process/service/light-probe-bake.ts | 23 +++---- .../scene-process/service/lightmap-bake.ts | 21 +++--- .../test/lightfx-result-failures.test.ts | 61 +++++++++++++++-- .../test/lightfx-scene-operation.test.ts | 14 ++++ 10 files changed, 227 insertions(+), 49 deletions(-) diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md index 92aae4bb0..a16ef62f8 100644 --- a/docs/dev/scene/lightfx-bake.md +++ b/docs/dev/scene/lightfx-bake.md @@ -340,7 +340,7 @@ Scene 侧 `LightProbeBake.cancel()`/`LightmapBake.cancel()` 只取消本 rende ## 场景会话与资产规则 -- 原生烘焙期间允许打开或重载场景;结果应用前校验启动时的 Scene 实例及编辑器会话代次。同 UUID 重载也视为新会话,旧任务拒绝应用,不保存新场景、不清理旧资产。 +- Bake/Clear 在申请 Host 事务前同步捕获源 Scene 实例及编辑器会话代次;申请完成后再次校验,若已切换或重载则释放该事务并拒绝执行,不对新场景烘焙、清理或保存。原生烘焙期间允许打开或重载场景,结果应用前仍校验同一源会话;同 UUID 重载也视为新会话。 - 结果应用、Undo 录制、保存和清理在原会话的生命周期队列内完成,打开、关闭、重载不会穿插其间。此保护不等同于锁住所有普通属性编辑;烘焙期间仍应避免修改输入几何和灯光。 - 新产物先导入独立暂存目录:默认 `db://assets//lightmap/bake-/`,指定父目录时为 `/bake-/`。原生提交前失败或取消只回滚本轮产物。 - 保存并清理旧产物成功后,PNG 保留 UUID 移动到 `<父目录>/scene-<完整场景UUID>/output/`。默认父目录为 `db://assets/LightFX`;`outputUrl: "db://assets"` 与省略相同。相同名称或相同自选父目录的不同场景也互相隔离。调用方必须使用返回的 `textureUrls`,不要拼路径。 diff --git a/src/core/assets/test/manager-filesystem.test.ts b/src/core/assets/test/manager-filesystem.test.ts index 8f84d6bff..ff9824220 100644 --- a/src/core/assets/test/manager-filesystem.test.ts +++ b/src/core/assets/test/manager-filesystem.test.ts @@ -44,7 +44,7 @@ jest.mock('../asset-config', () => ({ describe('asset filesystem manager', () => { beforeEach(() => { jest.resetModules(); - jest.clearAllMocks(); + jest.resetAllMocks(); }); it('should expose a provider-shaped local fallback and keep fallback methods after partial override', () => { @@ -118,9 +118,10 @@ describe('asset filesystem manager', () => { expect(mockRemove).not.toHaveBeenCalled(); }); - it('moveAssetSource should delegate rename to custom provider for source and meta files', async () => { + it.each([undefined, false, true])('moveAssetSource forwards overwrite=%s to custom provider for both files', async overwrite => { const filesystem = require('../manager/filesystem') as typeof import('../manager/filesystem'); const provider = { + readFile: jest.fn(async () => Buffer.from('{"uuid":"source"}')), rename: jest.fn(async () => {}), }; const source = 'D:/project/assets/source.txt'; @@ -128,10 +129,36 @@ describe('asset filesystem manager', () => { filesystem.setFileSystemProvider(provider); - await filesystem.moveAssetSource(source, target, { overwrite: false }); + await filesystem.moveAssetSource(source, target, overwrite === undefined ? undefined : { overwrite }); - expect(provider.rename).toHaveBeenNthCalledWith(1, `${source}.meta`, `${target}.meta`, { overwrite: true }); - expect(provider.rename).toHaveBeenNthCalledWith(2, source, target, { overwrite: false }); + // A non-overwriting move must not replace another asset's UUID either. + expect(provider.rename).toHaveBeenNthCalledWith(1, `${source}.meta`, `${target}.meta`, { overwrite: !!overwrite }); + expect(provider.rename).toHaveBeenNthCalledWith(2, source, target, { overwrite: !!overwrite }); + expect(provider.rename).toHaveBeenCalledTimes(2); + if (!overwrite) { + expect(provider.readFile).toHaveBeenCalledWith(`${source}.meta`, undefined); + } + expect(mockReadFile).not.toHaveBeenCalled(); expect(mockMove).not.toHaveBeenCalled(); }); + + it('does not move either file if the custom provider cannot read metadata for safe recovery', async () => { + const filesystem = require('../manager/filesystem') as typeof import('../manager/filesystem'); + const error = new Error('metadata read denied'); + const provider = { + readFile: jest.fn(async () => { throw error; }), + rename: jest.fn(async () => {}), + }; + filesystem.setFileSystemProvider(provider); + const log = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(filesystem.moveAssetSource('source.txt', 'target.txt')).rejects.toBe(error); + + expect(provider.rename).not.toHaveBeenCalled(); + expect(mockReadFile).not.toHaveBeenCalled(); + expect(mockMove).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + } + }); }); diff --git a/src/core/assets/test/move-source-failure.test.ts b/src/core/assets/test/move-source-failure.test.ts index 4eb3bf2f6..7b712c605 100644 --- a/src/core/assets/test/move-source-failure.test.ts +++ b/src/core/assets/test/move-source-failure.test.ts @@ -3,13 +3,16 @@ import { join } from 'path'; import { tmpdir } from 'os'; jest.mock('../asset-config', () => ({ __esModule: true, default: { data: {} } })); -jest.mock('../../base/utils', () => ({ __esModule: true, default: { Path: { contains: () => false } } })); +jest.mock('../../base/utils', () => ({ __esModule: true, default: { Path: jest.requireActual('../../base/utils/path') } })); +import assetConfig from '../asset-config'; import { moveAssetSource, resetFileSystemProvider, setFileSystemProvider } from '../manager/filesystem'; -describe('non-overwriting asset source move failure', () => { +describe('asset source move safety and compatibility', () => { let root: string, source: string, target: string; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'asset-move-failure-')); + assetConfig.data.root = root; + assetConfig.data.tempRoot = join(root, 'temp'); source = join(root, 'source.png'); target = join(root, 'output.png'); await outputFile(source, 'new pixels'); @@ -32,15 +35,67 @@ describe('non-overwriting asset source move failure', () => { expect(await pathExists(`${target}.meta`)).toBe(false); expect(await pathExists(target)).toBe(false); }); - it('does not overwrite target metadata that appears before the move', async () => { + it.each([undefined, { overwrite: false }])('does not overwrite target metadata with options %j', async options => { await outputFile(`${target}.meta`, 'unrelated'); - await expect(moveAssetSource(source, target, { overwrite: false })).rejects.toThrow(); + await expect(moveAssetSource(source, target, options)).rejects.toThrow(); expect(await readFile(`${target}.meta`, 'utf8')).toBe('unrelated'); expect(await readFile(`${source}.meta`, 'utf8')).toBe('{"uuid":"original"}'); expect(await pathExists(source)).toBe(true); }); - it('still moves both files with their UUID on success', async () => { - await moveAssetSource(source, target, { overwrite: false }); + it.each([undefined, { overwrite: false }, { overwrite: true }])('moves both files with their UUID using options %j', async options => { + await moveAssetSource(source, target, options); + expect(await readFile(target, 'utf8')).toBe('new pixels'); + expect(await readFile(`${target}.meta`, 'utf8')).toBe('{"uuid":"original"}'); + expect(await pathExists(source)).toBe(false); + expect(await pathExists(`${source}.meta`)).toBe(false); + }); + it('replaces both the target source and metadata when overwrite is explicitly allowed', async () => { + await outputFile(target, 'old pixels'); + await outputFile(`${target}.meta`, '{"uuid":"old-target"}'); + + await moveAssetSource(source, target, { overwrite: true }); + + expect(await readFile(target, 'utf8')).toBe('new pixels'); + expect(await readFile(`${target}.meta`, 'utf8')).toBe('{"uuid":"original"}'); + expect(await pathExists(source)).toBe(false); + expect(await pathExists(`${source}.meta`)).toBe(false); + }); + it('preserves both assets when the target already exists and overwrite is omitted', async () => { + await outputFile(target, 'unrelated pixels'); + await outputFile(`${target}.meta`, '{"uuid":"unrelated"}'); + + await expect(moveAssetSource(source, target)).rejects.toThrow(); + + expect(await readFile(source, 'utf8')).toBe('new pixels'); + expect(await readFile(`${source}.meta`, 'utf8')).toBe('{"uuid":"original"}'); + expect(await readFile(target, 'utf8')).toBe('unrelated pixels'); + expect(await readFile(`${target}.meta`, 'utf8')).toBe('{"uuid":"unrelated"}'); + }); + it.each(['sibling', 'nested'])('preserves directory and child UUIDs when moving to a %s path', async location => { + const folder = join(root, 'folder'); + const destination = location === 'nested' ? join(folder, 'nested') : join(root, 'moved-folder'); + await outputFile(`${folder}.meta`, '{"uuid":"folder"}'); + await outputFile(join(folder, 'child.png'), 'child pixels'); + await outputFile(join(folder, 'child.png.meta'), '{"uuid":"child"}'); + + await moveAssetSource(folder, destination); + + expect(await readFile(`${destination}.meta`, 'utf8')).toBe('{"uuid":"folder"}'); + expect(await readFile(join(destination, 'child.png'), 'utf8')).toBe('child pixels'); + expect(await readFile(join(destination, 'child.png.meta'), 'utf8')).toBe('{"uuid":"child"}'); + expect(await pathExists(`${folder}.meta`)).toBe(false); + expect(await pathExists(join(folder, 'child.png'))).toBe(false); + expect(await pathExists(join(root, 'temp', 'move-temp', location === 'nested' ? 'folder/nested' : 'moved-folder'))).toBe(false); + }); + it('uses the local metadata reader when only rename is overridden', async () => { + const rename = jest.fn(async (from: string, to: string, options?: { overwrite?: boolean }) => { + await move(from, to, { overwrite: !!options?.overwrite }); + }); + setFileSystemProvider({ rename }); + + await moveAssetSource(source, target); + + expect(rename).toHaveBeenCalledTimes(2); expect(await readFile(target, 'utf8')).toBe('new pixels'); expect(await readFile(`${target}.meta`, 'utf8')).toBe('{"uuid":"original"}'); expect(await pathExists(source)).toBe(false); diff --git a/src/core/assets/test/operation-filesystem-bridge.test.ts b/src/core/assets/test/operation-filesystem-bridge.test.ts index 17d408be4..707397b54 100644 --- a/src/core/assets/test/operation-filesystem-bridge.test.ts +++ b/src/core/assets/test/operation-filesystem-bridge.test.ts @@ -19,7 +19,7 @@ const mockQueryUrl = jest.fn(); const mockAssetQueryUrl = jest.fn(); const mockRefresh = jest.fn(async (_pathOrUrlOrUUID: string) => 0); const mockReimport = jest.fn(); -const mockAddTask = jest.fn(async (func: Function, args: any[]) => await func(...args)); +const mockAddTask = jest.fn(async (func: (...args: any[]) => unknown, args: any[]) => await func(...args)); const mockAutoRefreshAssetLazy = jest.fn(); const mockGetCreateMenuByName = jest.fn(); const mockCreateAssetByHandler = jest.fn(); @@ -99,7 +99,7 @@ jest.mock('../manager/asset-copy', () => ({ jest.mock('../manager/asset-db', () => ({ __esModule: true, default: { - addTask: (func: Function, args: any[]) => mockAddTask(func, args), + addTask: (func: (...args: any[]) => unknown, args: any[]) => mockAddTask(func, args), autoRefreshAssetLazy: (...args: any[]) => mockAutoRefreshAssetLazy(...args), assetDBInfo: {}, assetDBMap: {}, @@ -467,6 +467,30 @@ describe('asset operation filesystem bridge', () => { expect(mockMoveAssetSource).toHaveBeenCalledWith(source, target, undefined); }); + it('moveAsset rejects without refreshing the database when the source move fails', async () => { + const { assetOperation } = require('../manager/operation') as typeof import('../manager/operation'); + const source = 'D:/project/assets/source.txt'; + const target = 'D:/project/assets/folder/source.txt'; + mockQueryAsset.mockReturnValue({ + source, + _parent: null, + isDirectory: () => false, + _assetDB: { options: { readonly: false } }, + url: 'db://assets/source.txt', + }); + mockExistsSync.mockReturnValue(false); + mockQueryUrl.mockReturnValue('db://assets/folder/source.txt'); + const error = new Error('source move failed'); + mockMoveAssetSource.mockRejectedValueOnce(error); + + await expect(assetOperation.moveAsset(source, target, { overwrite: false })).rejects.toBe(error); + + expect(mockMoveAssetSource).toHaveBeenCalledWith(source, target, { overwrite: false }); + expect(mockRefresh).not.toHaveBeenCalled(); + expect(mockAutoRefreshAssetLazy).not.toHaveBeenCalled(); + expect(mockAddTask).toHaveBeenCalledTimes(1); + }); + it('importAsset should delegate copy to filesystem bridge', async () => { const { assetOperation } = require('../manager/operation') as typeof import('../manager/operation'); const source = 'D:/outside/source.txt'; diff --git a/src/core/scene/scene-process/service/baking/lightfx/scene-context.ts b/src/core/scene/scene-process/service/baking/lightfx/scene-context.ts index 5b341c118..6c9b17517 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/scene-context.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/scene-context.ts @@ -1,6 +1,24 @@ import { director, type Scene } from 'cc'; import { Service } from '../../core'; import type { IEditorSessionService } from '../../core/editor-session'; +import { lightFXSceneOperation } from './scene-operation'; +import type { LightFXBakeTarget } from './types'; + +export type LightFXSceneContext = ReturnType; + +/** Capture the caller's scene before reserving the host; never retarget an accepted request. */ +export function runLightFXSceneOperation(target: LightFXBakeTarget, action: 'bake' | 'clear', operation: (context: LightFXSceneContext) => Promise): Promise { + let context: LightFXSceneContext; + return lightFXSceneOperation.run(target, action, () => { + // Inside the reservation's cleanup scope so rejection releases this exact token. + context.assertCurrent(); + return operation(context); + }, () => { + const scene = director.getScene(); + if (!scene) throw new Error('No scene is currently open.'); + context = captureLightFXScene(scene); + }); +} /** Pin both the saved editor session and the actual Scene, including same-URL reloads. */ export function captureLightFXScene(scene: Scene) { @@ -13,6 +31,7 @@ export function captureLightFXScene(scene: Scene) { }; assertCurrent(); return { + scene, assertCurrent, // Only result application/save/cleanup holds the lifecycle queue; native work does not. run(operation: (save: () => Promise) => Promise): Promise { diff --git a/src/core/scene/scene-process/service/baking/lightfx/scene-operation.ts b/src/core/scene/scene-process/service/baking/lightfx/scene-operation.ts index af73ae181..fd4137b8b 100644 --- a/src/core/scene/scene-process/service/baking/lightfx/scene-operation.ts +++ b/src/core/scene/scene-process/service/baking/lightfx/scene-operation.ts @@ -14,7 +14,7 @@ export class LightFXSceneOperation { return this.transactionId; } - async run(target: LightFXBakeTarget, action: 'bake' | 'clear', operation: () => Promise): Promise { + async run(target: LightFXBakeTarget, action: 'bake' | 'clear', operation: () => Promise, beforeReserve?: () => void): Promise { if (this.active) { throw new Error(`A ${this.active.target} LightFX ${this.active.action} operation is already in progress.`); } @@ -23,6 +23,8 @@ export class LightFXSceneOperation { const owner = { target, action }; this.active = owner; try { + // Capture synchronous request context after the busy check but before the first await. + beforeReserve?.(); const token = await this.host.reserveSceneOperation(owner); this.transactionId = token.transactionId; let result: T; diff --git a/src/core/scene/scene-process/service/light-probe-bake.ts b/src/core/scene/scene-process/service/light-probe-bake.ts index 9bbf1b3ce..250947524 100644 --- a/src/core/scene/scene-process/service/light-probe-bake.ts +++ b/src/core/scene/scene-process/service/light-probe-bake.ts @@ -1,4 +1,4 @@ -import { director, Scene, SH, Vec3 } from 'cc'; +import { SH, Vec3 } from 'cc'; import type { ILightFXBakeEvents, ILightFXCancelResult, @@ -9,11 +9,10 @@ import type { } from '../../common'; import { lightFXCoordinator, LightFXBakeOutput } from './baking/lightfx/baker'; import { createDefaultLightFXSettings } from './baking/lightfx/settings'; -import { lightFXSceneOperation } from './baking/lightfx/scene-operation'; import { lightFXBakeHost } from './baking/lightfx/host'; import { finishSavedLightFXRecording, LightFXResultRetainedError } from './baking/lightfx/saved-recording'; import { BaseService, register, Service } from './core'; -import { captureLightFXScene } from './baking/lightfx/scene-context'; +import { runLightFXSceneOperation, type LightFXSceneContext } from './baking/lightfx/scene-context'; interface ProbeSnapshot { normal: Vec3; @@ -43,15 +42,12 @@ export class LightProbeBakeService extends BaseService imple } async bake(options: ILightProbeBakeOptions = {}): Promise { - return lightFXSceneOperation.run('light-probe', 'bake', () => this.bakeExclusive(options)); + return runLightFXSceneOperation('light-probe', 'bake', context => this.bakeExclusive(options, context)); } - private async bakeExclusive(options: ILightProbeBakeOptions): Promise { + private async bakeExclusive(options: ILightProbeBakeOptions, context: LightFXSceneContext): Promise { const started = Date.now(); - const scene = director.getScene() as Scene | null; - if (!scene) throw new Error('No scene is currently open.'); - - const context = captureLightFXScene(scene); + const { scene } = context; const sceneUrl = await this.querySceneUrl(); context.assertCurrent(); const info: any = scene.globals.lightProbeInfo; @@ -122,13 +118,12 @@ export class LightProbeBakeService extends BaseService imple } async clearBake(options: { saveScene?: boolean } = {}): Promise<{ probeCount: number }> { - return lightFXSceneOperation.run('light-probe', 'clear', () => this.clearBakeExclusive(options)); + return runLightFXSceneOperation('light-probe', 'clear', context => this.clearBakeExclusive(options, context)); } - private async clearBakeExclusive(options: { saveScene?: boolean }): Promise<{ probeCount: number }> { - const scene = director.getScene(); - if (!scene) throw new Error('No scene is currently open.'); - return captureLightFXScene(scene).run(async save => { + private async clearBakeExclusive(options: { saveScene?: boolean }, context: LightFXSceneContext): Promise<{ probeCount: number }> { + const { scene } = context; + return context.run(async save => { const info: any = scene.globals.lightProbeInfo; const probes: any[] = info.data?.probes ?? []; const previous = this.snapshot(probes); diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts index 35f74e8b5..fca1fa18b 100644 --- a/src/core/scene/scene-process/service/lightmap-bake.ts +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -8,13 +8,12 @@ import { lightFXCoordinator } from './baking/lightfx/baker'; import type { LightFXBakeOutput } from './baking/lightfx/baker'; import { lightFXBakeHost } from './baking/lightfx/host'; import { createDefaultLightFXSettings } from './baking/lightfx/settings'; -import { lightFXSceneOperation } from './baking/lightfx/scene-operation'; import { finishSavedLightFXRecording, LightFXResultRetainedError } from './baking/lightfx/saved-recording'; import { deletedLightmapAssets } from './baking/lightfx/deleted-lightmap-assets'; import { BaseService, register, Service } from './core'; import { loadPreviewAsset } from './preview/asset-reload'; import { validateLightmapGISamples } from '../../common/lightfx-limits'; -import { captureLightFXScene } from './baking/lightfx/scene-context'; +import { runLightFXSceneOperation, type LightFXSceneContext } from './baking/lightfx/scene-context'; interface LightmapBinding { target: any; @@ -40,15 +39,12 @@ export class LightmapBakeService extends BaseService impleme async bake(options: ILightmapBakeOptions = {}): Promise { // Scene callers (including PinK) do not necessarily pass through the public API schema. if (options.giSamples !== undefined) validateLightmapGISamples(options.giSamples); - return lightFXSceneOperation.run('lightmap', 'bake', () => this.bakeExclusive(options)); + return runLightFXSceneOperation('lightmap', 'bake', context => this.bakeExclusive(options, context)); } - private async bakeExclusive(options: ILightmapBakeOptions): Promise { + private async bakeExclusive(options: ILightmapBakeOptions, context: LightFXSceneContext): Promise { const started = Date.now(); - const scene = director.getScene() as Scene | null; - if (!scene) throw new Error('No scene is currently open.'); - - const context = captureLightFXScene(scene); + const { scene } = context; const sceneUrl = await this.querySceneUrl(); // Preflight before native publication or scene mutation, not after a successful save. const capabilities = await lightFXBakeHost.queryCapabilities(); @@ -219,13 +215,12 @@ export class LightmapBakeService extends BaseService impleme } async clearBake(options: { saveScene?: boolean; deleteAssets?: boolean } = {}): Promise { - return lightFXSceneOperation.run('lightmap', 'clear', () => this.clearBakeExclusive(options)); + return runLightFXSceneOperation('lightmap', 'clear', context => this.clearBakeExclusive(options, context)); } - private async clearBakeExclusive(options: { saveScene?: boolean; deleteAssets?: boolean }): Promise { - const scene = director.getScene() as Scene | null; - if (!scene) throw new Error('No scene is currently open.'); - return captureLightFXScene(scene).run(async save => { + private async clearBakeExclusive(options: { saveScene?: boolean; deleteAssets?: boolean }, context: LightFXSceneContext): Promise { + const { scene } = context; + return context.run(async save => { if (options.deleteAssets === true && options.saveScene === false) { throw new Error('deleteAssets requires saveScene so the saved scene cannot retain deleted lightmap references.'); } diff --git a/src/core/scene/test/lightfx-result-failures.test.ts b/src/core/scene/test/lightfx-result-failures.test.ts index a5f9ef746..03e83a7eb 100644 --- a/src/core/scene/test/lightfx-result-failures.test.ts +++ b/src/core/scene/test/lightfx-result-failures.test.ts @@ -1,4 +1,6 @@ const mockGetScene = jest.fn(); +let mockSessionGeneration = 0; +const mockReserveSceneOperation = jest.fn(), mockReleaseSceneOperation = jest.fn(); const mockMeshRenderer = class MeshRenderer {}; const mockTerrain = class Terrain {}; class MockVec3 { @@ -18,8 +20,8 @@ jest.mock('../scene-process/service/core', () => ({ BaseService: class { broadcast() {} }, register: () => () => undefined, Service: { Undo: mockUndo, Editor: { save: mockSave, querySceneSerializedData: mockQuerySceneSerializedData, - getEditorSession: () => ({ uuid: mockGetScene()?.uuid, generation: 0 }), - isCurrentEditorSession: (session: any) => session.uuid === mockGetScene()?.uuid, + getEditorSession: () => ({ uuid: mockGetScene()?.uuid, generation: mockSessionGeneration }), + isCurrentEditorSession: (session: any) => session.uuid === mockGetScene()?.uuid && session.generation === mockSessionGeneration, runForSession: async (_session: any, action: any) => action(mockSave), }, Engine: { repaintInEditMode: mockRepaint } }, })); @@ -28,8 +30,8 @@ jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoord publishLightmapAssets: async () => ({ textureUrls: ['db://assets/LightFX/output/LFX_Mesh_0000.png'] }), } })); jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { - reserveSceneOperation: async () => ({ transactionId: 'owner' }), releaseSceneOperation: async () => undefined, - queryCapabilities: async () => ({ lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapAuxiliaryAssetsVersion: 1 }), + reserveSceneOperation: mockReserveSceneOperation, releaseSceneOperation: mockReleaseSceneOperation, + queryCapabilities: async () => ({ lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapAuxiliaryAssetsVersion: 1 }), queryLightmapTextureInfo: async () => ({ textures: [], missingTextureUuids: [], ownedTextureUuids: [] }), } })); jest.mock('../scene-process/service/baking/lightfx/settings', () => ({ createDefaultLightFXSettings: () => ({}) })); @@ -102,12 +104,58 @@ function fixture(target: 'probe' | 'lightmap') { const service = target === 'probe' ? new LightProbeBakeService() : new LightmapBakeService(); jest.spyOn(service as any, 'querySceneUrl').mockResolvedValue('db://assets/test.scene'); if (service instanceof LightmapBakeService) jest.spyOn(service as any, 'loadOutputTextures').mockResolvedValue(new Map([['mesh:0', texture]])); - return { service, manager, read, disk: () => disk, assets: () => assets, events, save, + return { scene, service, manager, read, disk: () => disk, assets: () => assets, events, save, commit: async () => { committed = true; }, bake: () => service.bake({ giScale: 2, highp: true }), old: read() }; } +beforeEach(() => { + jest.resetAllMocks(); + mockSessionGeneration = 0; + mockReserveSceneOperation.mockResolvedValue({ transactionId: 'owner' }); + mockReleaseSceneOperation.mockResolvedValue(undefined); +}); + describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', target => { - beforeEach(() => { jest.resetAllMocks(); }); + for (const action of ['bake', 'clear', ...(target === 'lightmap' ? ['clear-delete'] : [])]) { + it.each(['switch', 'reload', 'session-generation'])(`${action} rejects %s during reservation and releases ownership for retry`, async change => { + const source = fixture(target); + const replacement = change === 'session-generation' ? source : fixture(target); + if (change === 'switch') replacement.scene.uuid = 'other-scene'; + mockGetScene.mockReturnValue(source.scene); + let reserve!: (token: { transactionId: string }) => void; + mockReserveSceneOperation.mockReturnValueOnce(new Promise(resolve => { reserve = resolve; })); + const invoke = () => action === 'bake' ? source.bake() + : source.service.clearBake({ saveScene: true, ...(action === 'clear-delete' ? { deleteAssets: true } : {}) }); + const pending = invoke(); + const rejected = expect(pending).rejects.toThrow('source scene changed'); + expect(mockReserveSceneOperation).toHaveBeenCalledTimes(1); + expect(mockBake).not.toHaveBeenCalled(); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + mockGetScene.mockReturnValue(replacement.scene); + if (change === 'session-generation') mockSessionGeneration++; + reserve({ transactionId: 'obsolete-owner' }); + await rejected; + expect(mockReleaseSceneOperation).toHaveBeenCalledTimes(1); + expect(mockReleaseSceneOperation).toHaveBeenCalledWith({ transactionId: 'obsolete-owner' }); + expect(mockBake).not.toHaveBeenCalled(); + expect(mockCommit).not.toHaveBeenCalled(); + expect(mockRollback).not.toHaveBeenCalled(); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + for (const f of [source, replacement]) { + expect(f.read()).toEqual(f.old); + expect(f.disk()).toEqual(f.old); + expect(f.assets()).toEqual(['old', 'new']); + } + // The rejected request must not poison the local reservation for the next request. + await invoke(); + expect(mockReserveSceneOperation).toHaveBeenCalledTimes(2); + expect(mockReleaseSceneOperation).toHaveBeenCalledTimes(2); + expect(mockReleaseSceneOperation).toHaveBeenLastCalledWith({ transactionId: 'owner' }); + expect(mockSave).toHaveBeenCalledTimes(1); + }); + } it('confirms asset retention before recording or saving', async () => { const f = fixture(target); await f.bake(); @@ -233,7 +281,6 @@ describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', t }); describe('Lightmap first bake history', () => { - beforeEach(() => jest.resetAllMocks()); it('restores an empty binding on Undo and preserves later rebake and Clear records', async () => { const f = fixture('lightmap'); mockGetScene().getComponents(mockMeshRenderer)[0].bakeSettings.texture = null; diff --git a/src/core/scene/test/lightfx-scene-operation.test.ts b/src/core/scene/test/lightfx-scene-operation.test.ts index 6c4e62515..dedaf1807 100644 --- a/src/core/scene/test/lightfx-scene-operation.test.ts +++ b/src/core/scene/test/lightfx-scene-operation.test.ts @@ -11,6 +11,20 @@ const targets = ['light-probe', 'lightmap'] as const; const actions = ['bake', 'clear'] as const; describe('LightFX scene-local transactions', () => { + it('captures request context synchronously and releases the local guard when capture fails', async () => { + const host = { reserveSceneOperation: jest.fn(async () => ({ transactionId: 'owner' })), releaseSceneOperation: jest.fn(async () => undefined) }; + const guard = new LightFXSceneOperation(host); + const operation = jest.fn(async () => 1); + const capture = jest.fn(() => { throw new Error('No source scene'); }); + const failed = guard.run('lightmap', 'clear', operation, capture); + expect(capture).toHaveBeenCalledTimes(1); + await expect(failed).rejects.toThrow('No source scene'); + expect(host.reserveSceneOperation).not.toHaveBeenCalled(); + expect(host.releaseSceneOperation).not.toHaveBeenCalled(); + expect(operation).not.toHaveBeenCalled(); + await expect(guard.run('lightmap', 'clear', operation)).resolves.toBe(1); + }); + it('does not run scene code or release another owner when the host rejects reservation', async () => { const host = { reserveSceneOperation: jest.fn(async () => { throw new Error('Host busy'); }), releaseSceneOperation: jest.fn() }; const operation = jest.fn(); From 6f5c5153caf1f92b6321d4c40568c70a5f108242 Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Sat, 12 Sep 2026 15:24:20 +0800 Subject: [PATCH 63/64] fix(scene): reset probe gestures and stabilize renderer timeout tests --- .../service/gizmo/gizmo-operation.ts | 82 +++--------- .../scene/test/light-probe-selection.test.ts | 126 ++++++++++++++++++ tests/reflection-probe-renderer.test.ts | 44 +++++- 3 files changed, 185 insertions(+), 67 deletions(-) diff --git a/src/core/scene/scene-process/service/gizmo/gizmo-operation.ts b/src/core/scene/scene-process/service/gizmo/gizmo-operation.ts index 4086ac021..3e775e6a2 100644 --- a/src/core/scene/scene-process/service/gizmo/gizmo-operation.ts +++ b/src/core/scene/scene-process/service/gizmo/gizmo-operation.ts @@ -96,19 +96,17 @@ class GizmoOperation { private endProbeRegion(): void { this._probeRegionDown = undefined; this._probeRegionDragged = false; - this._curMouseDownInfos.length = 0; - this._gizmoMouseDownEvent = null; - this._noGizmoMouseDownEvent = null; + this.clearMouseDownState(); getServiceProp('Gizmo')?.execGizmoMethods('cc.LightProbeGroup', 'endRegion'); this._hideSelectionRegion(); } - // ── light-probe vertex 模式:从「空白/线框处」起手的框选 ────────────── - // 命中的是 gizmo(线框等)但不是探针球时,若处于 vertex 模式,也允许在此起手框选, - // 这样「点探针拖」与「点空白拖」都能出现白色选区框并框选探针。 - private _probeRegionActive = false; - private _probeRegionDragging = false; - private _probeRegionDownEvent: GizmoMouseEvent | null = null; + private clearMouseDownState(): void { + this._curMouseDownInfos.length = 0; + this._gizmoMouseDownEvent = null; + this._noGizmoMouseDownEvent = null; + this._mouseDownRaycastGizmos = null; + } /** * Raycast against gizmo nodes @@ -203,64 +201,16 @@ class GizmoOperation { this._emitEventToNode(info.node, event); if (event.propagationStopped) break; } - // light-probe vertex 模式下:若命中的 gizmo 不是探针球(没有探针处理器消费事件, - // propagationStopped 仍为 false,例如命中线框),则在此起手框选, - // 使「点空白/线框处拖动」也能画白框并框选探针。 - if (!event.propagationStopped && !event.ctrlKey && !event.shiftKey && !event.metaKey) { - const gizmoSvc = getServiceProp('Gizmo'); - if (gizmoSvc?.queryLightProbeEditMode?.()) { - this._probeRegionActive = true; - this._probeRegionDragging = false; - this._probeRegionDownEvent = event; - } - } return false; } return true; } - /** vertex 框选(从 gizmo 起手):拖动达到阈值即画白框并按矩形框选探针。 */ - private _handleProbeRegionMove(event: GizmoMouseEvent): void { - const down = this._probeRegionDownEvent; - if (!down) return; - const dx = event.x - down.x; - const dy = event.y - down.y; - const distance = Math.sqrt(dx * dx + dy * dy); - if (!this._probeRegionDragging && distance < 10) return; - this._probeRegionDragging = true; - - const revertX = down.x > event.x; - const revertY = down.y < event.y; - const left = revertX ? event.x : down.x; - const right = revertX ? down.x : event.x; - const bottom = revertY ? down.y : event.y; - const top = revertY ? event.y : down.y; - - // 画白色选区框(与场景节点框选同一套绘制)。 - this._showSelectionRegion(left, right, top, bottom); - // 每帧 additive=false:以当前矩形为准重算命中集,天然幂等、gizmo 实时居中。 - getServiceProp('Gizmo')?.regionSelectLightProbes?.(left, right, top, bottom, false); - } - private _onGizmoMouseUp(event: GizmoMouseEvent): boolean { // 与 cocos-editor 一致:相机移动中不处理 const cameraCtrl = getServiceProp('Camera')?.controller; if (cameraCtrl?.isMoving?.()) return true; - // vertex 框选(从 gizmo 起手)收尾:隐藏白框,重置状态。 - if (this._probeRegionActive) { - const wasDragging = this._probeRegionDragging; - this._probeRegionActive = false; - this._probeRegionDragging = false; - this._probeRegionDownEvent = null; - if (wasDragging) { - this._hideSelectionRegion(); - this._curMouseDownInfos.length = 0; - return false; - } - // 未拖动:当作普通点击,继续走下面命中节点的 mouseUp 派发。 - } - if (this._curMouseDownInfos.length > 0) { for (const info of this._curMouseDownInfos) { event.hitPoint = info.hitPoint; @@ -283,11 +233,6 @@ class GizmoOperation { } private _onGizmoMouseMove(event: GizmoMouseEvent, results: RaycastResults) { - // vertex 框选(从 gizmo 起手):优先处理,画白框 + 框选探针。 - if (this._probeRegionActive) { - this._handleProbeRegionMove(event); - return; - } if (this._curMouseDownInfos.length > 0) { const map = new Map(); results.forEach((info: any) => map.set(info.node, info.hitPoint || new Vec3())); @@ -302,6 +247,8 @@ class GizmoOperation { // --- Main event handlers --- public onMouseDown(event: ISceneMouseEvent): boolean | void { + // A fresh press must not inherit a region whose mouse-up was lost outside the view. + if (this._probeRegionDown) { this.endProbeRegion(); } this._gizmoMoved = false; this._anyKeyDown = event.altKey || event.ctrlKey || event.shiftKey || event.metaKey; @@ -318,6 +265,7 @@ class GizmoOperation { if (results.length > 0) { this._gizmoMouseDownEvent = customEvent; const result = this._onGizmoMouseDown(customEvent, results); + // Blank-space and unconsumed Gizmo hits share one region state and cleanup path. if (!this.beginProbeRegion(customEvent)) { getServiceProp('Gizmo')?.execGizmoMethods('cc.LightProbeGroup', 'endRegion'); } return result; } @@ -355,7 +303,7 @@ class GizmoOperation { const customEvent = createGizmoMouseEvent('mouseMove', event); const probeDown = this._probeRegionDown; if (probeDown) { - if (!getServiceProp('Gizmo')?.queryLightProbeEditMode?.()) { this.endProbeRegion(); return false; } + if (!customEvent.leftButton || !getServiceProp('Gizmo')?.queryLightProbeEditMode?.()) { this.endProbeRegion(); return false; } if (Math.hypot(customEvent.x - probeDown.x, customEvent.y - probeDown.y) < 10 && !this._probeRegionDragged) { return false; } this._probeRegionDragged = true; const left = Math.min(probeDown.x, customEvent.x); @@ -618,10 +566,12 @@ class GizmoOperation { } public clear() { - this._gizmoMouseDownEvent = null; - this._noGizmoMouseDownEvent = null; + if (this._probeRegionDown) { + this.endProbeRegion(); + } else { + this.clearMouseDownState(); + } this._hoverInNodeMap.clear(); - this._curMouseDownInfos.length = 0; } } diff --git a/src/core/scene/test/light-probe-selection.test.ts b/src/core/scene/test/light-probe-selection.test.ts index 6fcf28454..910793e7a 100644 --- a/src/core/scene/test/light-probe-selection.test.ts +++ b/src/core/scene/test/light-probe-selection.test.ts @@ -1,4 +1,33 @@ import { ProbeSelection } from '../scene-process/service/gizmo/components/light-probe-group/selection'; +import type { ISceneMouseEvent } from '../scene-process/service/operation/types'; + +const mockRaycastGizmos = jest.fn(); +const mockService = { + Gizmo: { + gizmoRootNode: {}, + queryLightProbeEditMode: jest.fn(() => true), + execGizmoMethods: jest.fn(), + regionSelectLightProbes: jest.fn(), + unselectAllLightProbes: jest.fn(), + }, + Camera: { controller: { isMoving: () => false } }, + Engine: { repaintInEditMode: jest.fn() }, +}; + +jest.mock('../scene-process/service/core/decorator', () => ({ Service: mockService })); +jest.mock('cc', () => ({ + Vec3: class Vec3 { clone() { return new Vec3(); } }, + Event: class { constructor(public type: string) {} }, + Layers: { Enum: { IGNORE_RAYCAST: 0 } }, +})); +jest.mock('../scene-process/service/gizmo/utils/engine-utils', () => ({ + getRaycastResults: (...args: unknown[]) => mockRaycastGizmos(...args), +})); +jest.mock('../scene-process/service/gizmo/utils/node-utils', () => ({})); +jest.mock('../scene-process/service/gizmo/utils/selection-utils', () => ({})); +jest.mock('../scene-process/service/gizmo/utils/editor-node', () => ({})); + +import GizmoOperation from '../scene-process/service/gizmo/gizmo-operation'; describe('ProbeSelection', () => { it('does not carry selection across pooled targets, including equally sized groups', () => { @@ -54,3 +83,100 @@ describe('ProbeSelection', () => { expect([...selection.indices]).toEqual([]); }); }); + +describe('probe region gesture lifecycle', () => { + let previousCC: unknown; + + beforeEach(() => { + jest.clearAllMocks(); + mockService.Gizmo.queryLightProbeEditMode.mockReturnValue(true); + mockRaycastGizmos.mockReturnValue([]); + previousCC = (globalThis as any).cc; + (globalThis as any).cc = { game: { canvas: { height: 720 } } }; + }); + + afterEach(() => { + (globalThis as any).cc = previousCC; + }); + + function mouse(x: number, y: number): ISceneMouseEvent { + return { x, y, leftButton: true, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false } as ISceneMouseEvent; + } + + function beginRegion(operation: GizmoOperation, hit: 'wireframe' | 'blank'): void { + mockRaycastGizmos.mockReturnValue(hit === 'wireframe' ? [{ node: { emit: jest.fn() } }] : []); + operation.onMouseDown(mouse(10, 600)); + operation.onMouseMove(mouse(30, 580)); + expect(mockService.Gizmo.regionSelectLightProbes).toHaveBeenCalledTimes(1); + } + + function expectNextHandleDrag(operation: GizmoOperation): void { + const emitted: string[] = []; + const handle = { + emit(type: string, event: { propagationStopped: boolean }) { + emitted.push(type); + event.propagationStopped = true; + }, + }; + mockRaycastGizmos.mockReturnValue([{ node: handle }]); + mockService.Gizmo.regionSelectLightProbes.mockClear(); + operation.onMouseDown(mouse(80, 500)); + operation.onMouseMove(mouse(100, 480)); + operation.onMouseUp(mouse(100, 480)); + + expect(emitted).toEqual(['mouseDown', 'mouseMove', 'mouseUp']); + expect(mockService.Gizmo.regionSelectLightProbes).not.toHaveBeenCalled(); + } + + it.each(['wireframe', 'blank'] as const)('releases a completed %s box before the next consumed handle drag', hit => { + const operation = new GizmoOperation(); + beginRegion(operation, hit); + + operation.onMouseUp(mouse(30, 580)); + + expectNextHandleDrag(operation); + }); + + it.each(['wireframe', 'blank'] as const)('clear cancels an in-progress %s box before another handle drag', hit => { + const operation = new GizmoOperation(); + beginRegion(operation, hit); + + operation.clear(); + + expectNextHandleDrag(operation); + }); + + it('leaves ordinary Gizmo dispatch intact after exiting probe mode during a box gesture', () => { + const operation = new GizmoOperation(); + beginRegion(operation, 'wireframe'); + + mockService.Gizmo.queryLightProbeEditMode.mockReturnValue(false); + operation.onMouseMove(mouse(40, 570)); + operation.onMouseUp(mouse(40, 570)); + + expectNextHandleDrag(operation); + }); + + it('starts a fresh handle gesture if the previous probe mouse-up was lost', () => { + const operation = new GizmoOperation(); + beginRegion(operation, 'wireframe'); + + expectNextHandleDrag(operation); + }); + + it('stops an interrupted box when movement reports that the left button was released', () => { + const operation = new GizmoOperation(); + beginRegion(operation, 'wireframe'); + + operation.onMouseMove({ ...mouse(40, 570), leftButton: false }); + + expect(mockService.Gizmo.regionSelectLightProbes).toHaveBeenCalledTimes(1); + expectNextHandleDrag(operation); + }); + + it('forwards ordinary consumed handle events when probe editing is disabled', () => { + mockService.Gizmo.queryLightProbeEditMode.mockReturnValue(false); + + expectNextHandleDrag(new GizmoOperation()); + }); +}); diff --git a/tests/reflection-probe-renderer.test.ts b/tests/reflection-probe-renderer.test.ts index c72613732..70cbf09e0 100644 --- a/tests/reflection-probe-renderer.test.ts +++ b/tests/reflection-probe-renderer.test.ts @@ -95,7 +95,17 @@ function rendererSocket(options: IMockRendererOptions = {}) { } describe('reflection probe WebGL renderer bridge', () => { - beforeEach(() => jest.clearAllMocks()); + let nowMs: number; + let dateNow: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + nowMs = 10_000; + // Routing assertions must not depend on whether the real clock ticks during selection. + dateNow = jest.spyOn(Date, 'now').mockImplementation(() => nowMs); + }); + + afterEach(() => dateNow.mockRestore()); it('selects an explicitly requested scene', async () => { const other = rendererSocket({ id: 'other', sceneUrl: 'db://assets/Other.scene' }); @@ -193,6 +203,38 @@ describe('reflection probe WebGL renderer bridge', () => { ); }); + it.each(['capture', 'clear'] as const)('deducts source-selection time from the %s timeout budget', async action => { + const source = { runtimeId: 'runtime-a', sceneUuid: 'scene-a', generation: 2 }; + const active = rendererSocket({ source }); + const respond = active.emit.getMockImplementation()!; + active.emit.mockImplementation((event, request, reply) => { + if (event === 'scene:list-reflection-probes') { + // Model time spent confirming the source renderer, without a real sleep. + nowMs += 123; + } + respond(event, request, reply); + }); + mockFetchSockets.mockResolvedValue([active]); + + if (action === 'capture') { + await reflectionProbeRenderer.captureActive('Probe', 1500, source); + } else { + await reflectionProbeRenderer.clearActive(true, 1500, source); + } + + expect(active.timeout.mock.calls).toEqual([[1500], [1377]]); + expect(active.emit).toHaveBeenLastCalledWith( + action === 'capture' ? 'scene:capture-reflection-probe' : 'scene:clear-reflection-probes', + { + sceneUrl: 'db://assets/Target.scene', + source, + timeoutMs: 1377, + ...(action === 'capture' ? { nodePath: 'Probe' } : { saveScene: true }), + }, + expect.any(Function), + ); + }); + it('fails safely when multiple loaded scenes have not reported visibility', async () => { const first = rendererSocket({ id: 'first', sceneUrl: 'db://assets/First.scene' }); const second = rendererSocket({ id: 'second', sceneUrl: 'db://assets/Second.scene' }); From b84791521498ad120388da78d9d11cd1e039701e Mon Sep 17 00:00:00 2001 From: xubing0906 <58895777@qq.com> Date: Sat, 12 Sep 2026 15:33:12 +0800 Subject: [PATCH 64/64] refactor(lightfx): declare bake timeout timer as const --- src/core/scene/main-process/lightfx/process.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core/scene/main-process/lightfx/process.ts b/src/core/scene/main-process/lightfx/process.ts index a81c6e40b..ef1287a8e 100644 --- a/src/core/scene/main-process/lightfx/process.ts +++ b/src/core/scene/main-process/lightfx/process.ts @@ -45,7 +45,6 @@ export class LightFXProcess { this.settled = false; this.closePromise = null; await new Promise((resolve, reject) => { - let timer: NodeJS.Timeout; const finish = async (error?: unknown): Promise => { if (this.settled) { return; @@ -64,7 +63,7 @@ export class LightFXProcess { const succeed = (): void => { void finish(); }; const abort = (): void => fail(new Error('LightFX bake was cancelled.')); - timer = setTimeout(() => fail(new Error('LightFX bake timed out.')), options.timeoutMs); + const timer = setTimeout(() => fail(new Error('LightFX bake timed out.')), options.timeoutMs); options.signal?.addEventListener('abort', abort, { once: true }); void (async () => {