diff --git a/docs/dev/scene/reflection-probe-bake.md b/docs/dev/scene/reflection-probe-bake.md new file mode 100644 index 000000000..95744daf6 --- /dev/null +++ b/docs/dev/scene/reflection-probe-bake.md @@ -0,0 +1,89 @@ +# Reflection Probe Bake + +CLI 通过 MCP 工具 `scene-bake-reflection-probe` 烘焙立方体反射探针。它会捕获六面纹理、调用 cmft 生成 RGBM latlong PNG、导入 TextureCube、绑定组件,并按需保存场景。 + +## 使用条件 + +- CLI HTTP/MCP 服务已启动。 +- 浏览器已打开 `/scene-editor/`,目标场景显示为 `Loaded`。 +- 烘焙期间场景编辑器需要保持可见且可渲染;浏览器后台标签页或最小化窗口可能被节流并导致捕获超时。 +- `nodePath` 指向包含 `cc.ReflectionProbe` 的节点。 + +Node 场景进程使用 EmptyDevice,不能进行有效的 GPU 捕获。因此烘焙会将捕获请求转发给浏览器中的 WebGL 场景渲染器。没有可用渲染器时会明确失败,不会回退生成黑图;六面像素全部为空时也会停止并保留已有资源。 + +## MCP 调用 + +工具名:`scene-bake-reflection-probe` + +MCP Inspector 切换到 JSON 输入时,调用参数如下: + +```json +{ + "options": { + "nodePath": "Reflection Probe", + "saveScene": true, + "timeoutMs": 120000 + } +} +``` + +参数: + +- `nodePath`:探针节点在当前场景中的路径,必填。 +- `fastBake` 直接读取场景中 ReflectionProbe 组件的当前配置。 +- `saveScene`:绑定后保存场景,默认 `true`。 +- `timeoutMs`:完整流程超时,默认 120 秒,最大 600 秒。 + +成功结果包含探针节点、组件 UUID、probe ID,以及生成的 TextureCube UUID 和 URL。 + +调用前应先通过 `scene-open` 打开场景,并在 `/scene-editor/` 中加载同一个场景。`nodePath` 是相对于场景根节点的节点路径,不是资源 URL 或 UUID。 + +## Pink 场景 Webview + +Pink 场景 Webview 中的场景服务运行在本地 WebGL 环境。完整烘焙仍应通过 MCP 工具调用:Sharp、cmft、文件写入和 Asset DB 导入依赖 Node 环境,不能只在 Webview 中完成。 + +MCP 工具在 Node 主进程执行,并经 Node IPC 进入 scene-process。由于 Node scene-process 使用 EmptyDevice,MCP 路径会额外请求已加载同一场景的 Pink Webview,通过其本地 `window.cli.Scene.ReflectionProbe.capturePixels()` 完成六面捕获;该方法是内部渲染桥,不是公开的完整烘焙入口。Asset DB、配置和文件系统等 Node 能力继续通过 RPC 调用。 + +## 处理链路 + +```text +MCP scene-bake-reflection-probe + -> scene process: 校验场景与探针 + -> main process: 请求已连接的 WebGL renderer + -> browser /scene-editor/: 捕获六面 RGBA + -> scene process: 写入临时 PNG + -> cmft: 生成 reflectionProbe_.png + -> asset-db: 导入 /textureCube 子资源 + -> ReflectionProbe.cubemap: 绑定、刷新预览球、保存场景 +``` + +主要实现: + +- API:`src/api/scene/reflection-probe.ts` +- WebGL 请求桥:`src/core/scene/main-process/reflection-probe-renderer.ts` +- 浏览器监听:`src/core/scene/scene-process/engine-bootstrap.ts` +- 捕获、转换、导入和绑定:`src/core/scene/scene-process/service/reflection-probe.ts` + +## 输出与兼容行为 + +- 输出位置:`assets//reflectionProbe_.png` +- TextureCube 子资源:`db://assets//reflectionProbe_.png/textureCube` +- 捕获分辨率、clear flag、背景色、visibility、probe size 和 `fastBake` 均读取 `ReflectionProbe` 组件当前配置;MCP 参数不会覆盖这些值。 +- cmft 参数保持 Creator 的 RGBM latlong 行为。 +- `fastBake=true` 写入 `mipBakeMode=1`;否则写入 `mipBakeMode=2`。 +- 六面 RGBA 会通过同一条 Socket.IO 消息从 WebGL 场景渲染器返回;1024 分辨率约为 24 MiB 原始数据、32 MiB Base64 数据,因此服务端保留 128 MiB 的单消息上限。 +- 重复烘焙复用资源身份,并清理旧卷积缓存后重新导入。 +- 绑定操作进入 Undo,成功后刷新探针管理器与预览球。 + +## 验证 + +```powershell +npm.cmd run compile +npm.cmd test -- --runInBand tests/reflection-probe-bake-api.test.ts tests/reflection-probe-renderer.test.ts +``` + +端到端验证还应确认: + +1. `/scene-editor/` 能看到天空盒和测试模型。 +2. MCP 调用返回 `code: 200` 和 TextureCube URL。 +3. Creator 重新打开场景后,探针预览球仍显示烘焙结果。 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 e2852a1c3..c6aa33f24 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 @@ -6707,6 +6707,22 @@ export declare interface IReferenceImageState { export declare interface IReferenceImageVisibilityOptions { desiredVisible: boolean; } +export declare interface IReflectionProbeBakeOptions { + nodePath: string; + saveScene?: boolean; + timeoutMs?: number; +} +export declare interface IReflectionProbeBakeResult { + nodePath: string; + componentUuid: string; + probeId: number; + cubemapUuid: string; + cubemapUrl: string; + fastBake: boolean; +} +export declare interface IReflectionProbeService extends IServiceEvents { + bake(options: IReflectionProbeBakeOptions): Promise; +} export declare interface IReloadOptions { urlOrUUID?: string; preserveUndoHistory?: boolean; @@ -6851,6 +6867,7 @@ export declare interface IServiceManager { SceneView: ISceneViewService; Preview: IPreviewService; UI: IUIService; + ReflectionProbe: IReflectionProbeService; ReferenceImage: IReferenceImageService; } export declare interface ISetParentParams { diff --git a/src/api/scene/reflection-probe-schema.ts b/src/api/scene/reflection-probe-schema.ts new file mode 100644 index 000000000..94edfc68b --- /dev/null +++ b/src/api/scene/reflection-probe-schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const SchemaReflectionProbeBakeOptions = z.object({ + nodePath: z.string().trim().min(1).describe('Path of the node containing cc.ReflectionProbe'), + saveScene: z.boolean().optional().default(true).describe('Save the current scene after binding the cubemap'), + timeoutMs: z.number().int().positive().max(600_000).optional().default(120_000) + .describe('Timeout for capture, cmft, asset import, binding, and scene save'), +}).describe('Reflection probe bake options'); + +export const SchemaReflectionProbeBakeResult = z.object({ + nodePath: z.string(), + componentUuid: z.string(), + probeId: z.number().int(), + cubemapUuid: z.string(), + cubemapUrl: z.string(), + fastBake: z.boolean(), +}).describe('Reflection probe bake result'); + +export type TReflectionProbeBakeOptions = z.infer; +export type TReflectionProbeBakeResult = z.infer; diff --git a/src/api/scene/reflection-probe.ts b/src/api/scene/reflection-probe.ts new file mode 100644 index 000000000..da225d078 --- /dev/null +++ b/src/api/scene/reflection-probe.ts @@ -0,0 +1,30 @@ +import { description, param, result, title, tool } from '../decorator/decorator'; +import { COMMON_STATUS, CommonResultType } from '../base/schema-base'; +import { Scene } from '../../core/scene'; +import { + SchemaReflectionProbeBakeOptions, + SchemaReflectionProbeBakeResult, + TReflectionProbeBakeOptions, + TReflectionProbeBakeResult, +} from './reflection-probe-schema'; + +export class ReflectionProbeApi { + @tool('scene-bake-reflection-probe') + @title('Bake reflection probe') + @description('Capture and bake a cube reflection probe, import its TextureCube, bind it to the component, and optionally save the scene.') + @result(SchemaReflectionProbeBakeResult) + async bake( + @param(SchemaReflectionProbeBakeOptions) options: TReflectionProbeBakeOptions, + ): Promise> { + try { + const data = await Scene.ReflectionProbe.bake(options); + return { code: COMMON_STATUS.SUCCESS, data }; + } catch (error) { + console.error(error); + return { + code: COMMON_STATUS.FAIL, + reason: error instanceof Error ? error.message : String(error), + }; + } + } +} diff --git a/src/api/scene/scene.ts b/src/api/scene/scene.ts index 94538dabb..7983b693f 100644 --- a/src/api/scene/scene.ts +++ b/src/api/scene/scene.ts @@ -22,6 +22,7 @@ import { Scene, TSceneTemplateType } from '../../core/scene'; import { ComponentApi } from './component'; import { NodeApi } from './node'; import { PrefabApi } from './prefab'; +import { ReflectionProbeApi } from './reflection-probe'; import { ReferenceImageApi } from './reference-image'; import { options } from '../../core/builder/platforms/android/i18n/en'; @@ -29,12 +30,14 @@ export class SceneApi { public component: ComponentApi; public node: NodeApi; public prefab: PrefabApi; + public reflectionProbe: ReflectionProbeApi; public referenceImage: ReferenceImageApi; constructor() { this.component = new ComponentApi(); this.node = new NodeApi(); this.prefab = new PrefabApi(); + this.reflectionProbe = new ReflectionProbeApi(); this.referenceImage = new ReferenceImageApi(); } diff --git a/src/core/scene/common/index.ts b/src/core/scene/common/index.ts index 04185174f..ff80baf37 100644 --- a/src/core/scene/common/index.ts +++ b/src/core/scene/common/index.ts @@ -16,5 +16,6 @@ export * from './gizmo'; export * from './scene-view'; export * from './preview'; export * from './ui'; +export * from './reflection-probe'; export * from './message'; export * from './reference-image'; diff --git a/src/core/scene/common/reflection-probe.ts b/src/core/scene/common/reflection-probe.ts new file mode 100644 index 000000000..47422bbc4 --- /dev/null +++ b/src/core/scene/common/reflection-probe.ts @@ -0,0 +1,27 @@ +import type { IServiceEvents } from '../scene-process/service/core'; + +export interface IReflectionProbeBakeOptions { + nodePath: string; + saveScene?: boolean; + timeoutMs?: number; +} + +export interface IReflectionProbeBakeResult { + nodePath: string; + componentUuid: string; + probeId: number; + cubemapUuid: string; + cubemapUrl: string; + fastBake: boolean; +} + +export interface IReflectionProbeEvents { + 'reflection-probe:bake-start': [nodePath: string]; + 'reflection-probe:bake-end': [nodePath: string, error?: string]; +} + +export interface IReflectionProbeService extends IServiceEvents { + bake(options: IReflectionProbeBakeOptions): Promise; +} + +export type IPublicReflectionProbeService = Omit; diff --git a/src/core/scene/main-process/index.ts b/src/core/scene/main-process/index.ts index a69ebfc95..a56929548 100644 --- a/src/core/scene/main-process/index.ts +++ b/src/core/scene/main-process/index.ts @@ -6,6 +6,8 @@ import { ComponentProxy } from './proxy/component-proxy'; import { AssetProxy } from './proxy/asset-proxy'; import { EngineProxy } from './proxy/engine-proxy'; import { PrefabProxy } from './proxy/prefab-proxy'; +import { ReflectionProbeProxy } from './proxy/reflection-probe-proxy'; +import { reflectionProbeRenderer } from './reflection-probe-renderer'; import { ReferenceImageProxy } from './proxy/reference-image-proxy'; import { assetManager } from '../../assets'; @@ -20,6 +22,7 @@ export interface IMainModule { 'programming': typeof scriptManager; 'sceneConfigInstance': typeof sceneConfigInstance; 'i18n': typeof i18n; + 'reflectionProbeRenderer': typeof reflectionProbeRenderer; 'referenceImageFiles': typeof referenceImageFiles; 'referenceImageStore': typeof referenceImageStore; } @@ -35,6 +38,7 @@ export const Scene = { Node: NodeProxy, // 组件相关的接口 Component: ComponentProxy, + ReflectionProbe: ReflectionProbeProxy, // 场景进程 worker: sceneWorker, }; diff --git a/src/core/scene/main-process/proxy/reflection-probe-proxy.ts b/src/core/scene/main-process/proxy/reflection-probe-proxy.ts new file mode 100644 index 000000000..b2667a772 --- /dev/null +++ b/src/core/scene/main-process/proxy/reflection-probe-proxy.ts @@ -0,0 +1,12 @@ +import type { + IPublicReflectionProbeService, + IReflectionProbeBakeOptions, + IReflectionProbeBakeResult, +} from '../../common'; +import { Rpc } from '../rpc'; + +export const ReflectionProbeProxy: IPublicReflectionProbeService = { + bake(options: IReflectionProbeBakeOptions): Promise { + return Rpc.getInstance().request('ReflectionProbe', 'bake', [options]); + }, +}; diff --git a/src/core/scene/main-process/reflection-probe-renderer.ts b/src/core/scene/main-process/reflection-probe-renderer.ts new file mode 100644 index 000000000..d1bbc3a77 --- /dev/null +++ b/src/core/scene/main-process/reflection-probe-renderer.ts @@ -0,0 +1,63 @@ +import type { RemoteSocket } from 'socket.io'; +import type { DefaultEventsMap } from 'socket.io/dist/typed-events'; +import { SCENE_RENDERER_ROOM, socketService } from '../../../server/socket'; + +export interface IReflectionProbeCaptureResult { + resolution: number; + faces: string[]; +} + +interface ICaptureResponse { + result?: IReflectionProbeCaptureResult; + error?: string; +} + +interface ICaptureRequest { + sceneUrl: string; + nodePath: string; + timeoutMs: number; +} + +type Socket = RemoteSocket; + +function requestSocket(socket: Socket, request: ICaptureRequest): Promise { + return new Promise((resolve, reject) => { + socket.timeout(request.timeoutMs).emit( + 'scene:capture-reflection-probe', + request, + (error: Error | null, response?: ICaptureResponse) => { + if (error) { + reject(error); + } else if (response?.result) { + resolve(response.result); + } else { + reject(new Error(response?.error || 'WebGL scene renderer returned no reflection-probe data.')); + } + }, + ); + }); +} + +export const reflectionProbeRenderer = { + async capture(sceneUrl: string, nodePath: string, timeoutMs: number): Promise { + const io = socketService.io; + if (!io) { + throw new Error('The WebGL scene renderer is unavailable because the HTTP server is not running.'); + } + const sockets = await io.in(SCENE_RENDERER_ROOM).fetchSockets(); + if (sockets.length === 0) { + throw new Error('Reflection Probe Bake requires a WebGL scene renderer. Open /scene-editor/ in a browser and retry.'); + } + + const socket = sockets.find((candidate) => candidate.data.sceneUrl === sceneUrl) ?? sockets[0]; + try { + return await requestSocket(socket, { sceneUrl, nodePath, timeoutMs }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + 'The selected WebGL scene renderer could not complete the reflection-probe capture. ' + + `Open /scene-editor/ and wait for it to finish loading, then retry. (${detail})`, + ); + } + }, +}; diff --git a/src/core/scene/main-process/rpc.ts b/src/core/scene/main-process/rpc.ts index f438f0848..20175d065 100644 --- a/src/core/scene/main-process/rpc.ts +++ b/src/core/scene/main-process/rpc.ts @@ -4,6 +4,7 @@ import { assetManager } from '../../assets'; import scriptManager from '../../scripting'; import { sceneConfigInstance } from '../scene-configs'; import i18n from '../../base/i18n'; +import { reflectionProbeRenderer } from './reflection-probe-renderer'; import { referenceImageFiles } from './reference-image-files'; import { referenceImageStore } from './reference-image-store'; @@ -40,6 +41,7 @@ export class RpcProxy { // Feature-owned Node modules: external file reads and serialized local configuration writes. referenceImageFiles, referenceImageStore, + reflectionProbeRenderer, }); console.log(`[Node] Scene Process RPC ready ${prc ? '(Attached)' : '(Detached - Web Mode)'}`); } diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index d96be91dc..d9585e413 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -231,6 +231,10 @@ async function setupBrowserInvokeChannel(serverURL: string) { return; } const socket = io(serverURL); + const querySceneUrl = async (): Promise => { + const current = await DecoratorService.Editor.queryCurrent(); + return (current as any)?.__identifier__?.assetUrl ?? (current as any)?.assetUrl ?? ''; + }; const invoke = (module: string, method: string, args?: any[]) => { try { const svc = (DecoratorService as any)[module]; @@ -246,12 +250,47 @@ async function setupBrowserInvokeChannel(serverURL: string) { invoke(msg.module, msg.method, msg.args); } }); + socket.on('scene:capture-reflection-probe', async ( + msg: { sceneUrl?: string; nodePath?: string; timeoutMs?: number }, + reply: (response: { result?: unknown; error?: string }) => void, + ) => { + try { + if (!msg?.sceneUrl || !msg?.nodePath) { + throw new Error('Invalid reflection-probe capture request.'); + } + const current = await DecoratorService.Editor.queryCurrent(); + const currentAssetUrl = (current as any)?.__identifier__?.assetUrl + ?? (current as any)?.assetUrl; + if (currentAssetUrl !== msg.sceneUrl) { + await DecoratorService.Editor.open({ urlOrUUID: msg.sceneUrl }); + } + socket.emit('scene-renderer:scene', { sceneUrl: msg.sceneUrl }); + const result = await (DecoratorService.ReflectionProbe as any).capturePixels( + msg.nodePath, + msg.timeoutMs, + ); + reply({ result }); + } catch (error) { + reply({ error: error instanceof Error ? error.message : String(error) }); + } + }); // 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.on('connect', () => { + // Join the renderer room immediately. Scene discovery must not + // delay the pre-existing design-resolution synchronization. + socket.emit('scene-renderer:register', { sceneUrl: '' }); + void querySceneUrl().then((sceneUrl) => { + socket.emit('scene-renderer:scene', { sceneUrl }); + }).catch(() => { + // A renderer without an open scene can open the requested + // scene when a bake starts. + }); + }); } catch (e) { console.warn('[engine-bootstrap] setup browser-invoke channel failed:', e); } diff --git a/src/core/scene/scene-process/service/index.ts b/src/core/scene/scene-process/service/index.ts index 90fb5273a..a83d1c321 100644 --- a/src/core/scene/scene-process/service/index.ts +++ b/src/core/scene/scene-process/service/index.ts @@ -18,6 +18,7 @@ export * from './scene-view'; export * from './particle'; export * from './preview'; export * from './ui'; +export * from './reflection-probe'; // Keep a runtime export so the web Scene bundle follows this decorator // registration module instead of replacing its CommonJS side-effect import // with an empty tree-shaken namespace. diff --git a/src/core/scene/scene-process/service/interfaces.ts b/src/core/scene/scene-process/service/interfaces.ts index 6dd7cc40f..58d0d558e 100644 --- a/src/core/scene/scene-process/service/interfaces.ts +++ b/src/core/scene/scene-process/service/interfaces.ts @@ -32,6 +32,8 @@ import { IPublicUIService, IUIService, IAnimationService, + IReflectionProbeService, + IPublicReflectionProbeService, IPublicReferenceImageService, IReferenceImageService, } from '../../common'; @@ -56,6 +58,7 @@ export interface IPublicServiceManager { SceneView: IPublicSceneViewService, Preview: IPublicPreviewService, UI: IPublicUIService, + ReflectionProbe: IPublicReflectionProbeService, ReferenceImage: IPublicReferenceImageService, } @@ -77,5 +80,6 @@ export interface IServiceManager { SceneView: ISceneViewService, Preview: IPreviewService, UI: IUIService, + ReflectionProbe: IReflectionProbeService, ReferenceImage: IReferenceImageService, } diff --git a/src/core/scene/scene-process/service/reflection-probe.ts b/src/core/scene/scene-process/service/reflection-probe.ts new file mode 100644 index 000000000..6d89f30ef --- /dev/null +++ b/src/core/scene/scene-process/service/reflection-probe.ts @@ -0,0 +1,621 @@ +'use strict'; + +import { ChildProcess, spawn } from 'child_process'; +import { + assert, + assetManager, + director, + Director, + gfx, + ReflectionProbe, + renderer, + TextureCube, +} from 'cc'; +import { ReflectionProbeManager } from 'cc/editor/reflection-probe'; +import { + copy, + ensureDir, + existsSync, + outputJson, + pathExists, + readJson, + readdir, + move, + remove, +} from 'fs-extra'; +import { basename, dirname, join } from 'path'; +import type { + IReflectionProbeBakeOptions, + IReflectionProbeBakeResult, + IReflectionProbeEvents, + IReflectionProbeService, +} from '../../common'; +import { NodeEventType } from '../../common'; +import { BaseService, register, Service } from './core'; +import { ServiceEvents } from './core/global-events'; +import { Rpc } from '../rpc'; + +const DEFAULT_TIMEOUT_MS = 120_000; +const POLL_INTERVAL_MS = 200; +const FACE_NAMES = ['px', 'nx', 'py', 'ny', 'pz', 'nz'] as const; + +interface IAssetInfo { + uuid: string; + url: string; + [key: string]: unknown; +} + +interface ICapturedFaces { + resolution: number; + faces: string[]; +} + +interface IOutputTransaction { + commit(): Promise; + rollback(): Promise; +} + +@register('ReflectionProbe') +export class ReflectionProbeService extends BaseService implements IReflectionProbeService { + private _baking = false; + private _cmftProcess: ChildProcess | null = null; + + public async bake(options: IReflectionProbeBakeOptions): Promise { + if (this._baking) { + throw new Error('A reflection probe bake is already in progress.'); + } + if (!options?.nodePath?.trim()) { + throw new Error('Reflection probe nodePath is required.'); + } + + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error('Reflection probe timeoutMs must be greater than zero.'); + } + + const deadline = Date.now() + timeoutMs; + const nodePath = options.nodePath.trim(); + this._baking = true; + this.broadcast('reflection-probe:bake-start', nodePath); + + try { + const node = this._getNodeByExactPath(nodePath); + if (!node) { + throw new Error(`Reflection probe node was not found: ${nodePath}`); + } + + const component = node.getComponent(ReflectionProbe); + if (!component) { + throw new Error(`Node does not contain cc.ReflectionProbe: ${nodePath}`); + } + if (!component.enabled || !node.activeInHierarchy) { + throw new Error(`Reflection probe is disabled or inactive: ${nodePath}`); + } + if (component.probeType !== renderer.scene.ProbeType.CUBE) { + throw new Error(`Only cube reflection probes can be baked: ${nodePath}`); + } + + const probe = component.probe; + const probeId = probe.getProbeId(); + const resolution = Number((component as any)._resolution); + if (!Number.isInteger(resolution) || resolution <= 0) { + throw new Error(`Reflection probe has an invalid resolution: ${resolution}`); + } + const sceneName = node.scene?.name; + if (!sceneName) { + throw new Error('No scene is currently open.'); + } + const fastBake = component.fastBake; + + const current = await Service.Editor.queryCurrent(); + const currentAssetUrl = ((current as any)?.__identifier__?.assetUrl + ?? (current as any)?.assetUrl) as string | undefined; + if (!currentAssetUrl) { + throw new Error('The currently opened scene has no asset URL.'); + } + + const captured = gfx.deviceManager.gfxDevice.gfxAPI === gfx.API.UNKNOWN + ? await Rpc.getInstance().request('reflectionProbeRenderer', 'capture', [ + currentAssetUrl, + nodePath, + Math.max(1, deadline - Date.now()), + ]) + : await this.capturePixels(nodePath, Math.max(1, deadline - Date.now())); + if (captured.resolution !== resolution || captured.faces.length !== 6) { + throw new Error('The WebGL scene renderer returned invalid reflection-probe faces.'); + } + + const assetRoot = await Rpc.getInstance().request('assetManager', 'queryPath', ['db://assets']) as string | null; + if (!assetRoot) { + throw new Error('The db://assets directory is unavailable.'); + } + + const sceneDir = join(assetRoot, sceneName); + const backupRoot = join(assetRoot, '..', 'temp', 'reflection-probe-bake'); + await ensureDir(sceneDir); + const facePaths = await this._writeFaces(captured.faces, sceneDir, probeId, resolution, deadline); + const outputBase = join(sceneDir, `reflectionProbe_${probeId}`); + const outputPath = `${outputBase}.png`; + const outputUrl = `db://assets/${sceneName}/reflectionProbe_${probeId}.png`; + const textureCubeUrl = `${outputUrl}/textureCube`; + const stagedBase = join(sceneDir, `.reflection-probe-${probeId}-${Date.now()}`); + const stagedOutputPath = `${stagedBase}.png`; + + try { + await this._runCmft(facePaths, stagedBase, deadline); + await this._prepareMeta(stagedOutputPath, fastBake, outputPath); + const outputTransaction = await this._replaceOutput( + stagedOutputPath, + outputPath, + backupRoot, + fastBake, + ); + try { + this._assertBeforeDeadline(deadline, 'asset import'); + await Rpc.getInstance().request('assetManager', 'refreshAsset', [outputUrl]); + if (!fastBake) { + await this._ensureConvolution(outputBase, outputUrl, deadline); + } + const cubeInfo = await this._waitForTextureCube(textureCubeUrl, deadline); + const textureCube = await this._loadTextureCube(cubeInfo.uuid, deadline); + const previousCubemap = component.cubemap; + const commandId = Service.Undo.beginRecording([component.uuid], { + label: 'Bake reflection probe', + scope: { + nodePath, + propPath: `_components.${node.components.indexOf(component)}._cubemap`, + editorType: 'scene', + }, + }); + try { + component.cubemap = textureCube; + this._notifyCubemapChanged(node, component); + await Service.Engine.repaintInEditMode(); + + if (options.saveScene !== false) { + this._assertBeforeDeadline(deadline, 'scene save'); + await Service.Editor.save({}); + } + await Service.Undo.endRecording(commandId); + } catch (error) { + Service.Undo.cancelRecording(commandId); + component.cubemap = previousCubemap; + this._notifyCubemapChanged(node, component); + await Service.Engine.repaintInEditMode(); + throw error; + } + await outputTransaction.commit(); + this.broadcast('reflection-probe:bake-end', nodePath); + return { + nodePath, + componentUuid: component.uuid, + probeId, + cubemapUuid: cubeInfo.uuid, + cubemapUrl: cubeInfo.url, + fastBake, + }; + } catch (error) { + await outputTransaction.rollback(); + await Rpc.getInstance().request('assetManager', 'refreshAsset', [outputUrl]).catch(() => undefined); + throw error; + } + } finally { + await Promise.all([ + ...facePaths, + stagedOutputPath, + `${stagedOutputPath}.meta`, + ].map(async (path) => remove(path).catch(() => undefined))); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.broadcast('reflection-probe:bake-end', nodePath, message); + throw error; + } finally { + if (this._cmftProcess) { + this._cmftProcess.kill(); + this._cmftProcess = null; + } + this._baking = false; + } + } + + /** + * Runs inside the browser scene client when the Node scene process uses EmptyDevice. + * Faces are base64 encoded so they can cross socket.io and process IPC unchanged. + */ + public async capturePixels(nodePath: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + if (gfx.deviceManager.gfxDevice.gfxAPI === gfx.API.UNKNOWN) { + throw new Error('Reflection-probe pixels cannot be captured with the headless EmptyDevice.'); + } + const node = this._getNodeByExactPath(nodePath); + if (!node) { + throw new Error(`Reflection probe node was not found in the WebGL scene renderer: ${nodePath}`); + } + const component = node.getComponent(ReflectionProbe); + if (!component) { + throw new Error(`Node does not contain cc.ReflectionProbe in the WebGL scene renderer: ${nodePath}`); + } + const resolution = Number((component as any)._resolution); + if (!Number.isInteger(resolution) || resolution <= 0) { + throw new Error(`Reflection probe has an invalid resolution: ${resolution}`); + } + + const deadline = Date.now() + timeoutMs; + component.probe.captureCubemap(); + await this._waitForCapture(component.probe, deadline); + const flip = director.root!.device.capabilities.clipSpaceMinZ === -1; + return { + resolution, + faces: component.probe.bakedCubeTextures.map((texture: unknown) => { + const pixels = this._readPixels(texture); + const data = flip ? this._flipImage(pixels, resolution, resolution) : pixels; + return this._encodeBase64(data); + }), + }; + } + + private async _waitForCapture(probe: any, deadline: number): Promise { + do { + this._assertBeforeDeadline(deadline, 'cubemap capture'); + // Subscribe before requesting a repaint. The browser editor renders + // on demand, so subscribing afterwards can miss the only frame and + // leave the bake waiting for an unrelated future repaint. + const endFrame = this._waitForEndFrame(deadline); + await Service.Engine.repaintInEditMode(); + await endFrame; + } while (typeof probe.isFinishedRendering === 'function' && !probe.isFinishedRendering()); + + if (!Array.isArray(probe.bakedCubeTextures) || probe.bakedCubeTextures.length !== 6) { + throw new Error('Reflection probe capture did not produce six render textures.'); + } + } + + private _getNodeByExactPath(path: string): any | null { + if (path === '/') { + return director.getScene(); + } + const segments = path.split('/').map((segment) => segment.trim()).filter(Boolean); + let current: any = director.getScene(); + for (const segment of segments) { + current = current?.children?.find((child: any) => child.name === segment) ?? null; + if (!current) { + return null; + } + } + return current; + } + + private _waitForEndFrame(deadline: number): Promise { + return new Promise((resolve, reject) => { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + reject(new Error('Reflection probe bake timed out during cubemap capture.')); + return; + } + const timer = setTimeout(() => { + director.off(Director.EVENT_END_FRAME, onFrame); + reject(new Error('Reflection probe bake timed out during cubemap capture.')); + }, remaining); + const onFrame = () => { + clearTimeout(timer); + resolve(); + }; + director.once(Director.EVENT_END_FRAME, onFrame); + }); + } + + private async _writeFaces( + faces: string[], + sceneDir: string, + probeId: number, + resolution: number, + deadline: number, + ): Promise { + const result: string[] = []; + try { + // Keep sharp out of the browser service initialization path. Its + // libvips bootstrap is Node-only and crashes the WebGL scene client. + const sharp = (await import('sharp')).default; + const decodedFaces = faces.map((face, index) => { + const data = Buffer.from(face, 'base64'); + if (data.length !== resolution * resolution * 4) { + throw new Error(`Reflection probe face ${FACE_NAMES[index]} has an invalid byte length.`); + } + return data; + }); + const hasAnyColor = decodedFaces.some((data) => { + for (let offset = 0; offset < data.length; offset += 4) { + if (data[offset] !== 0 || data[offset + 1] !== 0 || data[offset + 2] !== 0) { + return true; + } + } + return false; + }); + if (!hasAnyColor) { + throw new Error('All reflection probe faces are empty; refusing to overwrite the existing bake.'); + } + for (let i = 0; i < FACE_NAMES.length; i++) { + this._assertBeforeDeadline(deadline, 'render texture readback'); + const data = decodedFaces[i]; + const facePath = join(sceneDir, `.reflection-probe-${probeId}-${FACE_NAMES[i]}.png`); + result.push(facePath); + await sharp(data, { + raw: { width: resolution, height: resolution, channels: 4 }, + }).png().toFile(facePath); + } + return result; + } catch (error) { + await Promise.all(result.map(async (path) => remove(path).catch(() => undefined))); + throw error; + } + } + + private _readPixels(texture: any): Uint8Array { + const gfxTexture = texture?.getGFXTexture?.(); + if (!gfxTexture) { + throw new Error('Failed to access a reflection probe render texture.'); + } + const width = texture.width; + const height = texture.height; + const buffer = new Uint8Array(width * height * 4); + const region = new gfx.BufferTextureCopy(); + region.texExtent.width = width; + region.texExtent.height = height; + gfx.deviceManager.gfxDevice.copyTextureToBuffers(gfxTexture, [buffer], [region]); + return buffer; + } + + private _flipImage(data: Uint8Array, width: number, height: number): Uint8Array { + const result = new Uint8Array(data.length); + const rowBytes = width * 4; + for (let y = 0; y < height; y++) { + result.set(data.subarray(y * rowBytes, (y + 1) * rowBytes), (height - y - 1) * rowBytes); + } + return result; + } + + private _encodeBase64(data: Uint8Array): string { + let binary = ''; + const chunkSize = 0x8000; + for (let offset = 0; offset < data.length; offset += chunkSize) { + binary += String.fromCharCode(...data.subarray(offset, offset + chunkSize)); + } + return btoa(binary); + } + + private async _runCmft(facePaths: string[], outputBase: string, deadline: number): Promise { + const executable = this._resolveCmftExecutable(); + const args = [ + '--rgbm', + '--bypassoutputtype', + '--output0params', 'png,rgbm,latlong', + '--inputFacePosX', facePaths[0], + '--inputFaceNegX', facePaths[1], + '--inputFacePosY', facePaths[2], + '--inputFaceNegY', facePaths[3], + '--inputFacePosZ', facePaths[4], + '--inputFaceNegZ', facePaths[5], + '--output0', outputBase, + ]; + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error('Reflection probe bake timed out before cmft started.'); + } + + await new Promise((resolve, reject) => { + const child = this._cmftProcess = spawn(executable, args, { windowsHide: true }); + let stderr = ''; + child.stderr?.on('data', (data) => { stderr += String(data); }); + const timer = setTimeout(() => { + child.kill(); + reject(new Error('Reflection probe bake timed out while running cmft.')); + }, remaining); + child.once('error', (error) => { + clearTimeout(timer); + reject(new Error(`Failed to start cmft: ${error.message}`)); + }); + child.once('close', (code) => { + clearTimeout(timer); + this._cmftProcess = null; + if (code !== 0) { + reject(new Error(`cmft exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`)); + } else { + resolve(); + } + }); + }); + + if (!await pathExists(`${outputBase}.png`)) { + throw new Error(`cmft did not create the expected output: ${outputBase}.png`); + } + } + + private _resolveCmftExecutable(): string { + const suffix = process.platform === 'win32' ? '.exe' : ''; + // This service is also bundled for the browser WebGL renderer. Resolve + // the Node-only static path lazily so browser module initialization does + // not import GlobalPaths (which relies on __dirname). + const staticDir = join(__dirname, '../../../../../static'); + const candidates = [ + join(staticDir, `tools/cmft/cmftRelease64${suffix}`), + join(staticDir, `tools/cmft/cmft${suffix}`), + ]; + const executable = candidates.find(existsSync); + if (!executable) { + throw new Error(`cmft executable was not found (checked ${candidates.join(', ')}).`); + } + return executable; + } + + private async _prepareMeta(outputPath: string, fastBake: boolean, previousOutputPath?: string): Promise { + const metaPath = `${outputPath}.meta`; + let meta: any = {}; + const previousMetaPath = previousOutputPath ? `${previousOutputPath}.meta` : metaPath; + if (await pathExists(previousMetaPath)) { + meta = await readJson(previousMetaPath); + } + meta.ver ??= '0.0.0'; + meta.importer ??= '*'; + meta.imported = false; + meta.userData ??= {}; + meta.userData.type = 'texture cube'; + meta.userData.isRGBE = true; + meta.subMetas ??= {}; + meta.subMetas.b47c0 ??= {}; + meta.subMetas.b47c0.imported = false; + meta.subMetas.b47c0.userData ??= {}; + meta.subMetas.b47c0.userData.mipBakeMode = fastBake ? 1 : 2; + await outputJson(metaPath, meta, { spaces: 2 }); + } + + private async _replaceOutput( + stagedOutputPath: string, + outputPath: string, + backupRoot: string, + fastBake: boolean, + ): Promise { + await this._cleanupLegacyBackupMetas(outputPath); + const backupDir = join(backupRoot, `${process.pid}-${Date.now()}`); + await ensureDir(backupDir); + const outputBase = outputPath.slice(0, -4); + const targets = [outputPath, `${outputPath}.meta`, `${outputBase}_convolution`]; + const backups = targets.map((_target, index) => join(backupDir, String(index))); + const savedBackups: Array<{ target: string; backup: string }> = []; + + const restore = async () => { + await Promise.all(targets.map(async (target) => remove(target).catch(() => undefined))); + for (const { target, backup } of savedBackups) { + if (await pathExists(backup)) { + await copy(backup, target, { overwrite: true }); + } + } + await remove(backupDir).catch(() => undefined); + }; + + try { + for (let i = 0; i < targets.length; i++) { + if (await pathExists(targets[i])) { + await copy(targets[i], backups[i], { overwrite: false }); + savedBackups.push({ target: targets[i], backup: backups[i] }); + } + } + if (fastBake) { + await remove(`${outputBase}_convolution`).catch(() => undefined); + } else { + // Preserve AssetDB-generated meta files and their UUIDs while + // invalidating only the six stale convolution images. + await Promise.all(FACE_NAMES.map(async (_face, index) => ( + remove(join(`${outputBase}_convolution`, `mipmap_${index}.png`)).catch(() => undefined) + ))); + } + await move(stagedOutputPath, outputPath, { overwrite: true }); + await move(`${stagedOutputPath}.meta`, `${outputPath}.meta`, { overwrite: true }); + } catch (error) { + await restore(); + throw error; + } + + return { + commit: async () => { + await remove(backupDir).catch(() => undefined); + }, + rollback: restore, + }; + } + + private async _cleanupLegacyBackupMetas(outputPath: string): Promise { + const prefix = `${basename(outputPath)}.bake-backup-`; + const entries = await readdir(dirname(outputPath)).catch(() => []); + await Promise.all(entries + .filter((entry) => entry.startsWith(prefix) && entry.endsWith('.meta')) + .map(async (entry) => remove(join(dirname(outputPath), entry)).catch(() => undefined))); + } + + private _notifyCubemapChanged(node: any, component: ReflectionProbe): void { + ReflectionProbeManager.probeManager.updateBakedCubemap(component.probe); + ReflectionProbeManager.probeManager.updatePreviewSphere(component.probe); + ServiceEvents.emit('node:change', node, { + type: NodeEventType.SET_PROPERTY, + propPath: `_components.${node.components.indexOf(component)}._cubemap`, + }); + } + + private async _ensureConvolution(outputBase: string, outputUrl: string, deadline: number): Promise { + const convolutionDir = `${outputBase}_convolution`; + if (!await this._hasCompleteConvolution(convolutionDir)) { + // A brand-new PNG is imported in two stages: the image importer + // first creates the TextureCube subasset, then erp-texture-cube can + // run its convolution importer on the following refresh. + this._assertBeforeDeadline(deadline, 'texture cube convolution'); + await this._prepareMeta(`${outputBase}.png`, false); + await Rpc.getInstance().request('assetManager', 'refreshAsset', [outputUrl]); + } + while (Date.now() < deadline) { + if (await this._hasCompleteConvolution(convolutionDir)) { + return; + } + await this._delay(Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - Date.now()))); + } + throw new Error(`TextureCube convolution mipmaps were not generated before timeout: ${outputUrl}`); + } + + private async _hasCompleteConvolution(convolutionDir: string): Promise { + return (await Promise.all(FACE_NAMES.map((_face, index) => ( + pathExists(join(convolutionDir, `mipmap_${index}.png`)) + )))).every(Boolean); + } + + private async _waitForTextureCube(url: string, deadline: number): Promise { + let lastError: unknown; + while (Date.now() < deadline) { + try { + const info = await Rpc.getInstance().request('assetManager', 'queryAssetInfo', [url]) as IAssetInfo | null; + if (info?.uuid) { + return info; + } + } catch (error) { + lastError = error; + } + await this._delay(Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - Date.now()))); + } + const detail = lastError instanceof Error ? ` Last error: ${lastError.message}` : ''; + throw new Error(`TextureCube subasset was not imported before timeout: ${url}.${detail}`); + } + + private async _loadTextureCube(uuid: string, deadline: number): Promise { + while (Date.now() < deadline) { + const remaining = deadline - Date.now(); + const asset = await new Promise((resolve) => { + let settled = false; + const timer = setTimeout(() => { + settled = true; + resolve('timeout'); + }, remaining); + assetManager.loadAny(uuid, (error: Error | null, value: TextureCube) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(error ? null : value); + }); + }); + if (asset === 'timeout') { + break; + } + if (asset instanceof TextureCube) { + return asset; + } + await this._delay(Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - Date.now()))); + } + throw new Error(`TextureCube could not be loaded before timeout: ${uuid}`); + } + + private _assertBeforeDeadline(deadline: number, stage: string): void { + assert(Date.now() < deadline, `Reflection probe bake timed out during ${stage}.`); + } + + private _delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/server/socket.ts b/src/server/socket.ts index ca38fb715..746f4f24a 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; @@ -16,9 +18,22 @@ export class SocketService { // 与 HTTP 路由的 CORS(server.ts 的 app.use(cors),Access-Control-Allow-Origin: *)保持一致。 this.io = new Server(server, { cors: { origin: '*', methods: ['GET', 'POST'] }, + // Reflection-probe capture returns six raw RGBA faces from the WebGL + // scene client. A 1024px probe is ~24 MiB raw and ~32 MiB as base64. + maxHttpBufferSize: 128 * 1024 * 1024, }); this.io.on('connection', (socket: any) => { console.log(`socket ${socket.id} connected`); + socket.on('scene-renderer:register', (data?: { sceneUrl?: string }) => { + socket.join(SCENE_RENDERER_ROOM); + socket.data.sceneRenderer = true; + socket.data.sceneUrl = data?.sceneUrl || ''; + }); + socket.on('scene-renderer:scene', (data?: { sceneUrl?: string }) => { + if (socket.data.sceneRenderer) { + socket.data.sceneUrl = data?.sceneUrl || ''; + } + }); middlewareService.middlewareSocket.forEach((middleware) => { middleware.connection(socket); }); diff --git a/tests/reflection-probe-bake-api.test.ts b/tests/reflection-probe-bake-api.test.ts new file mode 100644 index 000000000..2ca166421 --- /dev/null +++ b/tests/reflection-probe-bake-api.test.ts @@ -0,0 +1,96 @@ +import 'reflect-metadata'; +import { COMMON_STATUS } from '../src/api/base/schema-base'; +import { + SchemaReflectionProbeBakeOptions, + SchemaReflectionProbeBakeResult, +} from '../src/api/scene/reflection-probe-schema'; + +const mockBake = jest.fn(); + +jest.mock('../src/api/decorator/decorator.js', () => ({ + description: () => jest.fn(), + param: () => jest.fn(), + result: () => jest.fn(), + title: () => jest.fn(), + tool: () => jest.fn(), +}), { virtual: true }); + +jest.mock('../src/core/scene', () => ({ + Scene: { + ReflectionProbe: { + bake: (...args: unknown[]) => mockBake(...args), + }, + }, +})); + +import { ReflectionProbeApi } from '../src/api/scene/reflection-probe'; + +describe('reflection probe bake API', () => { + beforeEach(() => mockBake.mockReset()); + + it('applies safe defaults and rejects invalid input', () => { + expect(SchemaReflectionProbeBakeOptions.parse({ nodePath: 'Probe' })).toEqual({ + nodePath: 'Probe', + saveScene: true, + timeoutMs: 120_000, + }); + expect(SchemaReflectionProbeBakeOptions.parse({ nodePath: 'Probe', fastBake: true })).toEqual({ + nodePath: 'Probe', + saveScene: true, + timeoutMs: 120_000, + }); + expect(() => SchemaReflectionProbeBakeOptions.parse({ nodePath: ' ' })).toThrow(); + expect(() => SchemaReflectionProbeBakeOptions.parse({ nodePath: 'Probe', timeoutMs: 0 })).toThrow(); + expect(() => SchemaReflectionProbeBakeOptions.parse({ nodePath: 'Probe', timeoutMs: 600_001 })).toThrow(); + }); + + it('accepts the public result shape', () => { + expect(SchemaReflectionProbeBakeResult.parse({ + nodePath: 'Probe', + componentUuid: 'component-uuid', + probeId: 3, + cubemapUuid: 'cubemap-uuid', + cubemapUrl: 'db://assets/Main/reflectionProbe_3.png/textureCube', + fastBake: true, + }).probeId).toBe(3); + }); + + it('forwards options and wraps success', async () => { + const data = { + nodePath: 'Probe', + componentUuid: 'component-uuid', + probeId: 1, + cubemapUuid: 'cube-uuid', + cubemapUrl: 'db://assets/Main/reflectionProbe_1.png/textureCube', + fastBake: true, + }; + mockBake.mockResolvedValue(data); + + const result = await new ReflectionProbeApi().bake({ + nodePath: 'Probe', + saveScene: true, + timeoutMs: 120_000, + }); + + expect(mockBake).toHaveBeenCalledWith({ + nodePath: 'Probe', + saveScene: true, + timeoutMs: 120_000, + }); + expect(result).toEqual({ code: COMMON_STATUS.SUCCESS, data }); + }); + + it('wraps service failures', async () => { + mockBake.mockRejectedValue(new Error('cmft failed')); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + const result = await new ReflectionProbeApi().bake({ + nodePath: 'Probe', + saveScene: true, + timeoutMs: 120_000, + }); + + expect(result).toEqual({ code: COMMON_STATUS.FAIL, reason: 'cmft failed' }); + errorSpy.mockRestore(); + }); +}); diff --git a/tests/reflection-probe-renderer.test.ts b/tests/reflection-probe-renderer.test.ts new file mode 100644 index 000000000..0b87c8a88 --- /dev/null +++ b/tests/reflection-probe-renderer.test.ts @@ -0,0 +1,50 @@ +const mockFetchSockets = jest.fn(); +const mockIn = jest.fn(() => ({ fetchSockets: mockFetchSockets })); + +jest.mock('../src/server/socket', () => ({ + SCENE_RENDERER_ROOM: 'scene-renderer', + socketService: { io: { in: mockIn } }, +})); + +import { reflectionProbeRenderer } from '../src/core/scene/main-process/reflection-probe-renderer'; + +function rendererSocket(sceneUrl: string, resolution = 64) { + const socket = { + data: { sceneUrl }, + timeout: jest.fn(), + emit: jest.fn((_event, _request, reply) => { + reply(null, { result: { resolution, faces: Array(6).fill('pixels') } }); + }), + }; + socket.timeout.mockReturnValue(socket); + return socket; +} + +describe('reflection probe WebGL renderer selection', () => { + beforeEach(() => jest.clearAllMocks()); + + it('uses the renderer room and selects only the client with the requested scene', async () => { + const other = rendererSocket('db://assets/Other.scene'); + const matching = rendererSocket('db://assets/Target.scene'); + mockFetchSockets.mockResolvedValue([other, matching]); + + await expect(reflectionProbeRenderer.capture( + 'db://assets/Target.scene', + 'Probe', + 1000, + )).resolves.toMatchObject({ resolution: 64 }); + + expect(mockIn).toHaveBeenCalledWith('scene-renderer'); + expect(matching.emit).toHaveBeenCalledTimes(1); + expect(other.emit).not.toHaveBeenCalled(); + }); + + it('fails without a registered WebGL renderer', async () => { + mockFetchSockets.mockResolvedValue([]); + await expect(reflectionProbeRenderer.capture( + 'db://assets/Target.scene', + 'Probe', + 1000, + )).rejects.toThrow('requires a WebGL scene renderer'); + }); +}); diff --git a/workflow/build-scene-bundle.js b/workflow/build-scene-bundle.js index d9e4eedf7..53b211b6b 100644 --- a/workflow/build-scene-bundle.js +++ b/workflow/build-scene-bundle.js @@ -42,7 +42,7 @@ async function buildSceneBundle() { 'fs', 'node:fs', 'fs-extra', 'graceful-fs', 'lodash', 'package.json', '@cocos/asset-db', 'constants', 'stream', 'assert', 'crypto', 'child_process', 'vm', 'buffer', 'tty', 'zlib', 'http', 'https', 'net', 'tls', 'dns', 'readline', 'punycode', - 'cc/mods-mgr', 'inherits', 'sys', 'url', 'process', 'proper-lockfile' + 'cc/mods-mgr', 'inherits', 'sys', 'url', 'process', 'proper-lockfile', 'sharp' ]; if (stubs.includes(id)) { return '\0smart-' + id;