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 75cf7973c..e7c6f5834 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 @@ -6059,6 +6059,9 @@ export declare interface ICameraConfig { aperture: number; shutter: number; iso: number; + far2D?: number; + near2D?: number; + wheelSpeed2D?: number; } export declare interface ICameraService { init(): void; diff --git a/src/core/scene/common/node.ts b/src/core/scene/common/node.ts index 3c79f54ac..a2f92fec9 100644 --- a/src/core/scene/common/node.ts +++ b/src/core/scene/common/node.ts @@ -497,6 +497,7 @@ export enum NodeEventType { NOTIFY_NODE_CHANGED = 'notify-node-changed', PREFAB_INFO_CHANGED = 'prefab-info-changed', // prefab数据改变 LIGHT_PROBE_CHANGED = 'light-probe-changed', // 光照探针数据改变 + LIGHT_PROBE_BAKING_CHANGED = 'light-probe-baking-changed', // 光照探针烘焙数据改变 // SET_PROPERTY = 'set-property', // 设置节点上的属性 diff --git a/src/core/scene/scene-configs.ts b/src/core/scene/scene-configs.ts index 1c1745e6c..1b4b01073 100644 --- a/src/core/scene/scene-configs.ts +++ b/src/core/scene/scene-configs.ts @@ -18,6 +18,9 @@ export interface ICameraConfig { aperture: number; shutter: number; iso: number; + far2D?: number; + near2D?: number; + wheelSpeed2D?: number; } export interface IRectSnapConfig { @@ -70,6 +73,15 @@ export interface ISceneConfig { * SceneView 配置 */ sceneView: ISceneViewConfig; + /** + * 各节点上编辑器相机的视角信息(按节点 uuid 存储),运行期由 Camera 服务写入。 + * 提供空默认值以避免首次读取时配置层抛错。 + */ + 'camera-infos'?: Record; + /** + * 记录过相机视角信息的节点 uuid 列表,运行期由 Camera 服务写入。 + */ + 'camera-uuids'?: string[]; } class SceneConfig { @@ -116,6 +128,9 @@ class SceneConfig { sceneView: { sceneLightOn: true, }, + // 运行期由 Camera 服务写入;提供空默认值,避免首次 get 时配置层抛错并被 RPC 中间件记为错误日志 + 'camera-infos': {}, + 'camera-uuids': [], }; private configInstance!: IBaseConfiguration; diff --git a/src/core/scene/scene-process/service/camera.ts b/src/core/scene/scene-process/service/camera.ts index d19636ad3..eb180b594 100644 --- a/src/core/scene/scene-process/service/camera.ts +++ b/src/core/scene/scene-process/service/camera.ts @@ -23,6 +23,7 @@ export class CameraService extends BaseService implements ICamera private _controllerFirstChange = false; private _currentUuid = ''; private _cameraInfos: Record = {}; + private _cameraUuids: string[] = []; get controller2D() { return this._controller2D; } get controller3D() { return this._controller3D; } @@ -113,8 +114,10 @@ export class CameraService extends BaseService implements ICamera this._detachSceneCameras(); - setTimeout(() => { + setTimeout(async () => { try { + // 每次打开场景都重新加载相机视角记忆,避免复用 CameraService 时取到上一个场景的数据 + await this.loadCameraInfos(); this._controller.updateGrid(); this.defaultFocus(this._currentUuid); Service.Engine.repaintInEditMode(); @@ -143,6 +146,22 @@ export class CameraService extends BaseService implements ICamera } } + /** + * 加载相机视角记忆(按 scene UUID 存储)。每次打开场景都要重新加载, + * 因为 CameraService 会在多个场景间复用,只在首次创建相机时加载会导致后续场景取到旧数据。 + */ + async loadCameraInfos(): Promise { + try { + const rpc = Rpc.getInstance(); + const cameraInfos = await rpc.request('sceneConfigInstance', 'get', ['camera-infos']); + const cameraUuids = await rpc.request('sceneConfigInstance', 'get', ['camera-uuids']); + this._cameraInfos = (cameraInfos as Record) || {}; + this._cameraUuids = (cameraUuids as string[]) || []; + } catch { + // camera-infos 不存在时使用默认空值 + } + } + private _applyGizmoDisplay(config: Partial): void { if (config.gridVisible !== undefined) this.setGridVisible(config.gridVisible, false); if (config.gridColor !== undefined) this.setGridColor(config.gridColor); @@ -152,11 +171,12 @@ export class CameraService extends BaseService implements ICamera } setGridColor(color: number[]): void { - const [r = 166, g = 166, b = 166] = color; - this._controller2D.lineColor = new Color(r, g, b, 255); - (this._controller3D as any).lineColor = new Color(r, g, b, 50); - this._controller2D.updateGrid(); + if (!color || color.length < 3) return; + const [r = 166, g = 166, b = 166, a = 255] = color; + this._controller3D.lineColor = new Color(r, g, b, a); this._controller3D.updateGrid(); + this._controller2D.lineColor = new Color(r, g, b, a); + this._controller2D.updateGrid(); Service.Engine?.repaintInEditMode?.(); } @@ -178,15 +198,24 @@ export class CameraService extends BaseService implements ICamera if (config.fov !== undefined) this.setCameraProperty({ fov: config.fov }, false); if (config.far !== undefined) { this._controller3D.far = config.far; - this._camera.far = config.far; + if (this._camera && !this.is2D) this._camera.far = config.far; } if (config.near !== undefined) { this._controller3D.near = config.near; - this._camera.near = config.near; + if (this._camera && !this.is2D) this._camera.near = config.near; } if (config.wheelSpeed !== undefined) this._controller3D.wheelSpeed = config.wheelSpeed; if (config.wanderSpeed !== undefined) this._controller3D.wanderSpeed = config.wanderSpeed; if (config.enableAcceleration !== undefined) this._controller3D.enableAcceleration = config.enableAcceleration; + if (config.far2D !== undefined) { + this._controller2D.far = config.far2D; + if (this._camera && this.is2D) this._camera.far = config.far2D; + } + if (config.near2D !== undefined) { + this._controller2D.near = config.near2D; + if (this._camera && this.is2D) this._camera.near = config.near2D; + } + if (config.wheelSpeed2D !== undefined) this._controller2D.wheelSpeed = config.wheelSpeed2D; if (config.aperture !== undefined || config.shutter !== undefined || config.iso !== undefined) { this.setCameraProperty({ aperture: config.aperture, @@ -306,6 +335,7 @@ export class CameraService extends BaseService implements ICamera resetCameraProperty(): void { this._controller3D.wanderSpeed = 10; this._controller3D.enableAcceleration = true; + this.setCameraProperty({ aperture: 19, shutter: 7, iso: 0 }, false); if (this.is2D) { this._controller2D.wheelSpeed = 6; this.setCameraProperty({ fov: 45, far: 10000, near: 6, clearColor: [48, 48, 48, 255] }); @@ -324,11 +354,15 @@ export class CameraService extends BaseService implements ICamera ? [Math.round(clearColor.r), Math.round(clearColor.g), Math.round(clearColor.b), Math.round(clearColor.a)] : [48, 48, 48, 255], fov: this._camera?.fov ?? 45, - far: this._camera?.far ?? this._controller3D.far, - near: this._camera?.near ?? this._controller3D.near, + // 3D 的 near/far 取自 3D 控制器,避免受当前激活相机(可能是 2D)影响 + far: this._controller3D.far, + near: this._controller3D.near, wheelSpeed: this._controller3D.wheelSpeed, wanderSpeed: this._controller3D.wanderSpeed, enableAcceleration: this._controller3D.enableAcceleration, + far2D: this._controller2D.far, + near2D: this._controller2D.near, + wheelSpeed2D: this._controller2D.wheelSpeed, aperture: typeof camera?.aperture === 'number' ? camera.aperture : 19, shutter: typeof camera?.shutter === 'number' ? camera.shutter : 7, iso: typeof camera?.iso === 'number' ? camera.iso : 0, @@ -382,6 +416,58 @@ export class CameraService extends BaseService implements ICamera return this._camera; } + getCurCameraInfo(): any { + const curCameraNode = this._controller3D.node; + const curCameraPos = curCameraNode.getWorldPosition(); + const curCameraRot = curCameraNode.getWorldRotation(); + const position = { x: curCameraPos.x, y: curCameraPos.y, z: curCameraPos.z }; + const rotation = { x: curCameraRot.x, y: curCameraRot.y, z: curCameraRot.z, w: curCameraRot.w }; + const sceneViewCenter = this._controller3D.sceneViewCenter; + const viewCenter = { x: sceneViewCenter.x, y: sceneViewCenter.y, z: sceneViewCenter.z }; + // 2D 视图状态(对齐 Creator:一并记录 contentRect + scale2D,使 2D 场景视角可完整恢复) + const rect2D = this._controller2D.contentRect; + const contentRect = { x: rect2D.x, y: rect2D.y, width: rect2D.width, height: rect2D.height }; + const scale = this._controller2D.scale2D; + return { position, rotation, viewCenter, contentRect, scale }; + } + + async saveCameraInfos(uuid?: string, write = true): Promise { + uuid = uuid ?? this._currentUuid; + if (!uuid) return; + const cameraInfo = this.getCurCameraInfo(); + const index = this._cameraUuids.indexOf(uuid); + + if (index !== -1) { + delete this._cameraInfos[uuid]; + this._cameraUuids.splice(index, 1); + this._cameraUuids.push(uuid); + } else { + this._cameraUuids.push(uuid); + if (this._cameraUuids.length > 50) { + delete this._cameraInfos[this._cameraUuids[0]]; + this._cameraUuids.splice(0, 1); + } + } + this._cameraInfos[uuid] = cameraInfo; + if (write) { + try { + const rpc = Rpc.getInstance(); + await rpc.request('sceneConfigInstance', 'set', ['camera-infos', this._cameraInfos]); + await rpc.request('sceneConfigInstance', 'set', ['camera-uuids', this._cameraUuids]); + } catch { + // persistence not available + } + } + } + + onEditorClosed(): void { + this.saveCameraInfos(undefined, false); + } + + onEditorSaved(): void { + void this.saveCameraInfos(); + } + /** * 与原始编辑器 ScenePreview.detachSceneCameras 一致: * 将所有非编辑器的场景相机从渲染管线中移除,并设置 tempWindow diff --git a/src/core/scene/scene-process/service/camera/camera-controller-3d.ts b/src/core/scene/scene-process/service/camera/camera-controller-3d.ts index 9ee792d34..815ed6313 100644 --- a/src/core/scene/scene-process/service/camera/camera-controller-3d.ts +++ b/src/core/scene/scene-process/service/camera/camera-controller-3d.ts @@ -1,4 +1,4 @@ -import { Camera, Color, gfx, js, Layers, Mat4, MeshRenderer, Node, Quat, Vec3, ISizeLike } from 'cc'; +import { Camera, Color, geometry, gfx, js, Layers, Mat4, MeshRenderer, Node, Quat, Vec3, ISizeLike } from 'cc'; import CameraControllerBase, { EditorCameraInfo } from './camera-controller-base'; import { CameraMoveMode, CameraUtils } from './utils'; import FiniteStateMachine from '../utils/state-machine/finite-state-machine'; @@ -29,6 +29,67 @@ function getWorldPosition3D(node: Node): Vec3 { return node.getWorldPosition(); } +function getBoundaryOfMeshNode(node: Node): geometry.AABB | null { + if (!node) return null; + const modelComp = node.getComponent(MeshRenderer); + if (!modelComp) return null; + + const SkinnedMeshRenderer = (cc as any).SkinnedMeshRenderer; + if (SkinnedMeshRenderer && modelComp instanceof SkinnedMeshRenderer) { + modelComp.model?.updateTransform?.(-1); + return modelComp.model?.worldBounds ?? null; + } + + if (modelComp.mesh && modelComp.model) { + let transformAABB = modelComp.model.modelBounds?.clone() ?? null; + if (!transformAABB) { + const mesh = modelComp.mesh; + if (mesh && mesh.minPosition && mesh.maxPosition) { + transformAABB = geometry.AABB.fromPoints(geometry.AABB.create(), mesh.minPosition, mesh.maxPosition); + } + } + if (transformAABB) { + geometry.AABB.transform(transformAABB, transformAABB, node.worldMatrix); + } + return transformAABB; + } + return null; +} + +// 引擎的粒子发射器形状枚举未从 cc 公开导出,这里与 Creator(utils/node.ts)一致, +// 按其稳定的序列化值定义一个本地 ShapeType 枚举,避免使用魔法数字。 +enum ShapeType { + Box = 0, + Circle = 1, + Cone = 2, + Sphere = 3, + Hemisphere = 4, +} + +function getRangeFromParticleComp(component: any): number { + let range = 0; + if (component.shapeModule?.enable) { + const shapeModule = component.shapeModule; + const s = shapeModule.scale; + // 引擎会把 shapeModule.scale 应用到所有发射器形状,这里对非 Box 形状也乘上最大轴缩放 + const maxScale = Math.max(Math.abs(s.x), Math.abs(s.y), Math.abs(s.z)); + switch (shapeModule.shapeType) { + case ShapeType.Box: + range = Math.max(Math.abs(s.x), Math.abs(s.y), Math.abs(s.z)); + break; + case ShapeType.Circle: + case ShapeType.Sphere: + case ShapeType.Hemisphere: + range = shapeModule.radius * maxScale; + break; + case ShapeType.Cone: + range = Math.max(shapeModule.radius, shapeModule.length) * maxScale; + break; + } + } + return range; +} + function getMaxRangeOfNode(node: Node): number { let maxRange = 0.001; @@ -53,6 +114,21 @@ function getMaxRangeOfNode(node: Node): number { case 'cc.PointLight': compRange = (component as any).range ?? 3; break; + case 'cc.LightProbeGroup': { + const comp = component as any; + if (comp.maxPos && comp.minPos) { + const probesSize = new Vec3(); + Vec3.subtract(probesSize, comp.maxPos, comp.minPos); + // minPos/maxPos 是本地空间,需乘节点世界缩放换算成世界半径(与 mesh 路径一致) + const ws = node.getWorldScale(); + compRange = Math.max( + Math.abs((probesSize.x / 2) * ws.x), + Math.abs((probesSize.y / 2) * ws.y), + Math.abs((probesSize.z / 2) * ws.z), + ); + } + break; + } case 'cc.RangedDirectionalLight': case 'cc.DirectionalLight': case 'cc.Camera': @@ -60,16 +136,31 @@ function getMaxRangeOfNode(node: Node): number { break; case 'cc.MeshRenderer': case 'cc.SkinnedMeshRenderer': + case 'cc.AvatarModelComponent': case 'cc.SkinnedMeshBatchRenderer': { const mr = component as MeshRenderer; if (mr.mesh && mr.model) { - const worldBound = mr.model.worldBounds; - if (worldBound) { - const he = worldBound.halfExtents; - if (!Number.isNaN(he.x) && !Number.isNaN(he.y) && !Number.isNaN(he.z)) { - compRange = Math.max(he.x, he.y, he.z); + let worldBound: any = mr.model.worldBounds; + + if (!worldBound) { + const modelBound = mr.model.modelBounds; + if (modelBound) { + worldBound = geometry.AABB.create(); + geometry.AABB.transform(worldBound, modelBound, node.worldMatrix); } } + + if (worldBound && ( + Number.isNaN(worldBound.halfExtents.x) + || Number.isNaN(worldBound.halfExtents.y) + || Number.isNaN(worldBound.halfExtents.z) + )) { + worldBound = getBoundaryOfMeshNode(node); + } + + if (worldBound) { + compRange = Math.max(worldBound.halfExtents.x, worldBound.halfExtents.y, worldBound.halfExtents.z); + } } break; } @@ -105,6 +196,23 @@ function getMaxRangeOfNode(node: Node): number { if (size) compRange = Math.max(size.x, size.y, size.z) / 2; break; } + case 'cc.ParticleSystem': { + // getRangeFromParticleComp 返回的是发射器本地空间尺寸,需按节点绝对世界缩放换算成世界半径 + const localRange = getRangeFromParticleComp(component); + const ws = node.getWorldScale(); + compRange = localRange * Math.max(Math.abs(ws.x), Math.abs(ws.y), Math.abs(ws.z)); + break; + } + default: { + const Terrain = (cc as any).Terrain; + if (Terrain && className === js.getClassName(Terrain)) { + const info = (component as any).info; + if (info?.size) { + compRange = Math.max(info.size.width / 2, info.size.height / 2); + } + } + break; + } } if (compRange > maxRange) { @@ -129,7 +237,7 @@ function getMaxRangeOfNodes(nodes: Node[]): number { if (childRange > maxRange) maxRange = childRange; } - return Math.max(maxRange, 1); + return maxRange; } function makeVec3InRange(v: Vec3, min: number, max: number): void { diff --git a/src/core/scene/scene-process/service/camera/camera-controller-base.ts b/src/core/scene/scene-process/service/camera/camera-controller-base.ts index a885919a1..aba8999de 100644 --- a/src/core/scene/scene-process/service/camera/camera-controller-base.ts +++ b/src/core/scene/scene-process/service/camera/camera-controller-base.ts @@ -7,6 +7,8 @@ export interface EditorCameraInfo { rotation?: Quat; viewCenter?: Vec3; viewDist?: number; + contentRect?: any; + scale?: number; } abstract class CameraControllerBase extends EventEmitter { diff --git a/src/core/scene/scene-process/service/gizmo.ts b/src/core/scene/scene-process/service/gizmo.ts index f9a8da08a..43e406c6b 100644 --- a/src/core/scene/scene-process/service/gizmo.ts +++ b/src/core/scene/scene-process/service/gizmo.ts @@ -38,6 +38,8 @@ import './gizmo/components/mesh-renderer'; import './gizmo/components/skinned-mesh-renderer'; import './gizmo/components/video-player'; import './gizmo/components/web-view'; +import './gizmo/components/light-probe-group'; +import './gizmo/components/reflection-probe'; type TGizmoType = 'icon' | 'persistent' | 'component'; @@ -909,6 +911,11 @@ export class GizmoService extends BaseService implements IGizmoSer onNodeChanged(node: Node, opts?: IChangeNodeOptions): void { if (!node) return; + // 光照探针数据变化(如探针组重新生成)时,探针组自身节点会收到该事件, + // 但受其影响的 mesh 的四面体高亮挂在别的节点上,需主动通知选中的探针消费者刷新。 + if (opts?.type === NodeEventType.LIGHT_PROBE_CHANGED || opts?.type === NodeEventType.LIGHT_PROBE_BAKING_CHANGED) { + this._scheduleProbeConsumersRefresh(); + } const has = this._selection.includes(node.uuid); walkNodeComponent(node, (component: Component) => { @@ -953,7 +960,9 @@ export class GizmoService extends BaseService implements IGizmoSer } }); - if (opts?.type !== NodeEventType.CHILD_CHANGED) { + if (opts?.type !== NodeEventType.CHILD_CHANGED + && opts?.type !== NodeEventType.LIGHT_PROBE_CHANGED + && opts?.type !== NodeEventType.LIGHT_PROBE_BAKING_CHANGED) { node.children.forEach((child) => { this.onNodeChanged(child, opts); }); @@ -962,6 +971,38 @@ export class GizmoService extends BaseService implements IGizmoSer Service.Engine?.repaintInEditMode?.(); } + /** + * 光照探针数据变化时,通知当前选中节点上的组件 gizmo(如 mesh/skinned 的影响四面体)刷新。 + * tetra helper 内部按签名短路,重复调用是廉价的。 + */ + private _notifyLightProbeChanged(): void { + for (const uuid of this._selection) { + const node = getNodeByUuid(uuid); + if (!node) continue; + walkNodeComponent(node, (component: Component) => { + const gizmo = getGizmoProperty('component', component); + if (gizmo && (gizmo as any).onLightProbeChanged && gizmo.checkVisible()) { + (gizmo as any).onLightProbeChanged(); + } + }); + } + } + + private _probeRefreshScheduled = false; + + /** + * 去抖:烘焙事件(LIGHT_PROBE_BAKING_CHANGED)会在场景所有节点上同步 emit, + * 用微任务把这一波折叠成一次刷新,避免每节点一次导致的重复重建。 + */ + private _scheduleProbeConsumersRefresh(): void { + if (this._probeRefreshScheduled) return; + this._probeRefreshScheduled = true; + Promise.resolve().then(() => { + this._probeRefreshScheduled = false; + this._notifyLightProbeChanged(); + }); + } + onComponentAdded(comp: Component): void { const node = comp.node; if (!node) return; 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 new file mode 100644 index 000000000..3d37b9b17 --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts @@ -0,0 +1,321 @@ +'use strict'; + +import { Color, js, LightProbeGroup, Node, Quat, Vec3 } from 'cc'; +import GizmoBase from '../../base/gizmo-base'; +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'; + +// 探针数量超过该阈值时只画包围盒/线框、不逐个建球,避免海量节点 +const MAX_PROBE_DOTS = 4096; +// 对齐 Cocos Creator LightProbeController 常量 +const PROBE_COLOR = new Color(241, 163, 72); // #F1A348 +const WIREFRAME_COLOR = new Color(252, 231, 196); // #FCE7C4 +const PROBE_SPHERE_BASE_RADIUS = 5; +// 内部四面体 6 条边 +const TETRAHEDRON_LINES = [0, 1, 0, 2, 0, 3, 1, 2, 1, 3, 2, 3]; + +const tempQuat_a = new Quat(); +const tempDelta = new Vec3(); + +/** + * 光照探针组(LightProbeGroup)选中 Gizmo — 对齐 Cocos Creator: + * - 全部探针小球(#F1A348,世界固定尺寸); + * - 整组内部四面体线框(#FCE7C4,取自 scene.globals.lightProbeInfo.data); + * - 绿色生成包围盒,支持逐面非对称拖拽(改 minPos/maxPos),松手重生成探针。 + */ +class LightProbeGroupComponentGizmo extends GizmoBase { + private _controller!: BoxController; + private _dotsRoot: Node | null = null; // 探针球容器(跟随节点世界变换) + private _wireframeNode: Node | null = null; // 四面体线框(世界坐标、单位阵) + private _probesRef: Vec3[] | null = null; + private _dotsVolume = -1; // 上次建点用的球体积,用于失效缓存 + private _reuseMesh: any = null; + private _lastInfoSig = ''; // lightProbeInfo 显示设置/数据签名,用于按需刷新 + + // mouseDown 时捕获 + private _minPos = new Vec3(); + private _maxPos = new Vec3(); + private _scale = new Vec3(); + private _minPropPath: string | null = null; + private _maxPropPath: string | null = null; + + init() { + this.createController(); + this._isInitialized = true; + } + + onShow() { + this._controller.show(); + this.updateControllerData(); + } + + onHide() { + this._controller.hide(); + if (this._dotsRoot) this._dotsRoot.active = false; + if (this._wireframeNode) this._wireframeNode.active = false; + this._lastInfoSig = ''; + } + + createController() { + const gizmoRoot = this.getGizmoRoot(); + this._controller = new BoxController(gizmoRoot); + this._controller.setColor(Color.GREEN); // 对齐 Creator 包围盒绿色 + this._controller.editable = true; + this._controller.hoverColor = Color.YELLOW; + this._controller.onControllerMouseDown = this.onControllerMouseDown.bind(this); + this._controller.onControllerMouseMove = this.onControllerMouseMove.bind(this); + this._controller.onControllerMouseUp = this.onControllerMouseUp.bind(this); + + this._dotsRoot = create3DNode('LightProbeDots'); + this._dotsRoot.parent = gizmoRoot; + this._dotsRoot.active = false; + + this._wireframeNode = create3DNode('LightProbeWireframe'); + this._wireframeNode.parent = gizmoRoot; + this._wireframeNode.active = false; + } + + onControllerMouseDown() { + 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._minPropPath = this.getCompPropPath('minPos'); + this._maxPropPath = this.getCompPropPath('maxPos'); + } + + onControllerMouseMove(event: any) { + this.updateDataFromController(event); + } + + onControllerMouseUp() { + if (this.target && this._controller.updated) { + // 依据新范围重生成探针,并刷新探针球/线框 + this.target.generateLightProbes(); + this._rebuildDots(true); + this._rebuildWireframe(); + this.onComponentChanged(this.target.node); + // 引擎重剖分四面体是延迟的,稍后补刷一次线框,避免与球错位(对齐 Creator debounce) + const target = this.target; + setTimeout(() => { + if (this.target === target) { + this._rebuildDots(true); + this._rebuildWireframe(); + } + }, 250); + } + this.onControlEnd(this._minPropPath); + this.onControlEnd(this._maxPropPath); + } + + // 逐面非对称编辑:neg 面改 minPos,正面改 maxPos(对齐 Creator updateDataFromBBController) + updateDataFromController(event: any) { + if (!this._controller.updated || !this.target) return; + this.onControlUpdate(this._minPropPath); + this.onControlUpdate(this._maxPropPath); + + const delta = tempDelta.set(this._controller.getDeltaSize()); + Vec3.divide(delta, delta, this._scale); + Vec3.multiplyScalar(delta, delta, 0.5); + + const handleName: string = event?.handleName ?? ''; + const newMin = new Vec3(this._minPos); + const newMax = new Vec3(this._maxPos); + if (handleName.includes('neg')) { + Vec3.subtract(newMin, this._minPos, delta); + } else { + Vec3.add(newMax, this._maxPos, delta); + } + this.target.minPos = newMin; + this.target.maxPos = newMax; + + const center = Vec3.multiplyScalar(new Vec3(), Vec3.add(new Vec3(), newMin, newMax), 0.5); + const size = Vec3.subtract(new Vec3(), newMax, newMin); + this._controller.updateSize(center, size); + this.onComponentChanged(this.target.node); + } + + updateControllerTransform() { + this.updateControllerData(); + } + + updateControllerData() { + if (!this._isInitialized || this.target == null) return; + if (!(this.target instanceof LightProbeGroup)) { + this._controller.hide(); + if (this._dotsRoot) this._dotsRoot.active = false; + if (this._wireframeNode) this._wireframeNode.active = false; + return; + } + + const node = this.target.node; + const worldScale = node.getWorldScale(); + const worldPos = node.getWorldPosition(); + const worldRot = tempQuat_a; + node.getWorldRotation(worldRot); + + // 生成包围盒 + this._controller.show(); + this._controller.checkEdit(); + this._controller.setScale(worldScale); + this._controller.setPosition(worldPos); + this._controller.setRotation(worldRot); + const min = this.target.minPos; + const max = this.target.maxPos; + const center = Vec3.multiplyScalar(new Vec3(), Vec3.add(new Vec3(), min, max), 0.5); + const fullSize = Vec3.subtract(new Vec3(), max, min); + this._controller.updateSize(center, fullSize); + + // 探针球容器跟随节点世界变换 + if (this._dotsRoot) { + this._dotsRoot.setWorldPosition(worldPos); + this._dotsRoot.setWorldRotation(worldRot); + this._dotsRoot.setWorldScale(worldScale); + } + this._rebuildDots(false); + this._rebuildWireframe(); + } + + private _getLightProbeInfo(): any { + return (this.target?.node as any)?.scene?.globals?.lightProbeInfo ?? null; + } + + /** 探针球:按 target.probes(节点本地坐标)画,仅引用变化时重建 */ + private _rebuildDots(force: boolean) { + if (!this._dotsRoot || !this.target) return; + const info = this._getLightProbeInfo(); + const showProbe = info ? (info.showProbe ?? true) : true; + this._dotsRoot.active = showProbe; + if (!showProbe) return; + + const probes = this.target.probes; + const volume = info?.lightProbeSphereVolume ?? 1.0; + // 缓存失效:probes 数组或球体积变化时才重建(体积影响球大小) + if (!force && probes === this._probesRef && volume === this._dotsVolume) return; + this._probesRef = probes; + this._dotsVolume = volume; + + this._dotsRoot.removeAllChildren(); + if (!probes || probes.length === 0 || probes.length > MAX_PROBE_DOTS) return; + + const scale = volume * 0.06; + for (let i = 0; i < probes.length; i++) { + let dot: Node; + if (!this._reuseMesh) { + dot = ControllerUtils.sphere(Vec3.ZERO, PROBE_SPHERE_BASE_RADIUS, PROBE_COLOR, { depthTestForTriangles: true }); + this._reuseMesh = getModel(dot)?.mesh; + } else { + // 复用首个球的 mesh,避免每个探针都新建网格 + dot = create3DNode(); + addMeshToNode(dot, this._reuseMesh, { depthTestForTriangles: true }); + setMeshColor(dot, PROBE_COLOR); + } + dot.parent = this._dotsRoot; + dot.setPosition(probes[i]); + dot.setScale(scale, scale, scale); + } + } + + /** 整组内部四面体线框:取自 lightProbeInfo.data(世界坐标) */ + private _rebuildWireframe() { + if (!this._wireframeNode || !this.target) return; + const info = this._getLightProbeInfo(); + const showWireframe = info ? (info.showWireframe ?? true) : true; + const data = info?.data; + if (!showWireframe || !data || data.empty?.()) { + this._wireframeNode.active = false; + return; + } + const vertices = data.probes ?? []; + const tetrahedrons = data.tetrahedrons ?? []; + if (vertices.length === 0 || tetrahedrons.length === 0) { + this._wireframeNode.active = false; + return; + } + const positions: Vec3[] = vertices.map((v: any) => v.position); + const indices: number[] = []; + const seen = new Set(); + for (const tet of tetrahedrons) { + if (!(tet.isInnerTetrahedron?.() ?? tet.vertex3 >= 0) || tet.vertex3 < 0) continue; + const vi = [tet.vertex0, tet.vertex1, tet.vertex2, tet.vertex3]; + for (let e = 0; e < TETRAHEDRON_LINES.length; e += 2) { + const a = vi[TETRAHEDRON_LINES[e]]; + const b = vi[TETRAHEDRON_LINES[e + 1]]; + const key = a < b ? `${a}-${b}` : `${b}-${a}`; + if (seen.has(key)) continue; + seen.add(key); + indices.push(a, b); + } + } + if (indices.length === 0) { + this._wireframeNode.active = false; + return; + } + this._wireframeNode.active = true; + this._wireframeNode.setWorldPosition(0, 0, 0); + this._wireframeNode.setRotationFromEuler(0, 0, 0); + this._wireframeNode.setWorldScale(1, 1, 1); + ControllerUtils.drawLines(this._wireframeNode, positions, indices, WIREFRAME_COLOR); + } + + onTargetUpdate() { + this.updateControllerData(); + } + + onNodeChanged() { + this.updateControllerData(); + } + + // 探针数据变化(重新生成/烘焙,可能顶点数不变但位置/系数变了):失效缓存并强制刷新, + // 避免 onUpdate 的计数签名相同而漏刷。 + onLightProbeChanged() { + this._probesRef = null; + this._dotsVolume = -1; + this._lastInfoSig = ''; + this.updateControllerData(); + } + + // lightProbeInfo 的显示设置/探针数据可能在没有节点变化时改变(如烘焙、面板开关、球体积)。 + // 每帧只做一次廉价签名比较,变化时才刷新,避免每帧重建。 + onUpdate() { + const sig = this._computeInfoSig(); + if (sig === this._lastInfoSig) return; + this._lastInfoSig = sig; + this.updateControllerData(); + } + + private _computeInfoSig(): string { + const info = this._getLightProbeInfo(); + const data = info?.data; + const probes = this.target?.probes; + return [ + probes ? probes.length : 0, + info ? (info.lightProbeSphereVolume ?? 1) : 1, + info ? (info.showProbe ?? true) : true, + info ? (info.showWireframe ?? true) : true, + data?.tetrahedrons?.length ?? 0, + data?.probes?.length ?? 0, + ].join('|'); + } + + onDestroy() { + if (this._dotsRoot) { + this._dotsRoot.destroy(); + this._dotsRoot = null; + } + if (this._wireframeNode) { + this._wireframeNode.destroy(); + this._wireframeNode = null; + } + } +} + +export const name = js.getClassName(LightProbeGroup); +// 仅选中 LightProbeGroup 节点时显示;选中“使用探针的物体”时的四面体见 utils/light-probe-tetra。 +export const SelectGizmo = LightProbeGroupComponentGizmo; +export const IconGizmo = null; +export const PersistentGizmo = null; + +registerGizmo(name, { SelectGizmo }); diff --git a/src/core/scene/scene-process/service/gizmo/components/mesh-renderer/index.ts b/src/core/scene/scene-process/service/gizmo/components/mesh-renderer/index.ts index f240a5334..d9de9f406 100644 --- a/src/core/scene/scene-process/service/gizmo/components/mesh-renderer/index.ts +++ b/src/core/scene/scene-process/service/gizmo/components/mesh-renderer/index.ts @@ -3,6 +3,7 @@ import { geometry, js, MeshRenderer, Quat, Vec3 } from 'cc'; import GizmoBase from '../../base/gizmo-base'; import BoxController from '../../controller/box'; +import { LightProbeTetraHelper } from '../../utils/light-probe-tetra'; import { registerGizmo } from '../../gizmo-defines'; const tempQuat_a = new Quat(); @@ -10,9 +11,11 @@ const tempSize = new Vec3(); class ModelComponentGizmo extends GizmoBase { private _controller!: BoxController; + private _tetraHelper!: LightProbeTetraHelper; init() { this._controller = new BoxController(this.getGizmoRoot()); + this._tetraHelper = new LightProbeTetraHelper(this.getGizmoRoot()); this._isInitialized = true; } @@ -23,6 +26,7 @@ class ModelComponentGizmo extends GizmoBase { onHide() { this._controller.hide(); + this._tetraHelper.hide(); } updateControllerData() { @@ -48,6 +52,9 @@ class ModelComponentGizmo extends GizmoBase { } else { this._controller.hide(); } + + // 影响该物体的光照探针四面体连线(仅当开启“使用光照探针”时显示) + this._tetraHelper.update(this.target); } private getBoundingBox(component: MeshRenderer): geometry.AABB | null { @@ -72,6 +79,22 @@ class ModelComponentGizmo extends GizmoBase { onNodeChanged() { this.updateControllerData(); } + + onUpdate() { + // 每帧调用,靠 helper 内部签名缓存兜底:签名含 tetrahedronIndex/volume/reduceRinging/ + // 顶点位置/SH 首系数,未变化时廉价短路,变化时(含移动跨四面体、球体积/reduceRinging 调整)才重建。 + if (this.target) this._tetraHelper.update(this.target); + } + + // 探针数据变化(探针组重生成/烘焙等,可能不改 index/签名输入)时失效缓存并强制刷新 + onLightProbeChanged() { + this._tetraHelper.invalidate(); + if (this.target) this._tetraHelper.update(this.target); + } + + onDestroy() { + this._tetraHelper?.destroy(); + } } export const name = js.getClassName(MeshRenderer); diff --git a/src/core/scene/scene-process/service/gizmo/components/reflection-probe/index.ts b/src/core/scene/scene-process/service/gizmo/components/reflection-probe/index.ts new file mode 100644 index 000000000..59b2dd038 --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/reflection-probe/index.ts @@ -0,0 +1,119 @@ +'use strict'; + +import { Color, js, Quat, ReflectionProbe, Vec3 } from 'cc'; +import GizmoBase from '../../base/gizmo-base'; +import BoxController from '../../controller/box'; +import { registerGizmo } from '../../gizmo-defines'; + +const tempVec3 = new Vec3(); +const tempQuat_a = new Quat(); + +/** + * 反射探针(ReflectionProbe)选中 Gizmo: + * 画出影响区域包围盒线框,并支持拖拽包围盒面手柄修改 size。 + * 注意 ReflectionProbe.size 是 AABB 半长,线框全尺寸 = size * 2。 + * PLANAR 类型的 size 为扁平值(默认 5,0.5,5),会显示成一块薄盒(平面)。 + */ +class ReflectionProbeComponentGizmo extends GizmoBase { + private _controller!: BoxController; + private _size: Vec3 = new Vec3(); + private _scale: Vec3 = new Vec3(); + private _propPath: string | null = null; + + init() { + this.createController(); + this._isInitialized = true; + } + + onShow() { + this._controller.show(); + this.updateControllerData(); + } + + onHide() { + this._controller.hide(); + } + + createController() { + const gizmoRoot = this.getGizmoRoot(); + this._controller = new BoxController(gizmoRoot); + // 青色以区别于碰撞盒的绿色 + this._controller.setColor(new Color(0, 200, 255)); + this._controller.editable = true; + this._controller.hoverColor = Color.YELLOW; + this._controller.onControllerMouseDown = this.onControllerMouseDown.bind(this); + this._controller.onControllerMouseMove = this.onControllerMouseMove.bind(this); + this._controller.onControllerMouseUp = this.onControllerMouseUp.bind(this); + } + + onControllerMouseDown() { + if (!this._isInitialized || this.target === null) return; + this._size = this.target.size.clone(); + this._scale = this.target.node.getWorldScale(); + this._propPath = this.getCompPropPath('size'); + } + + onControllerMouseMove() { + this.updateDataFromController(); + } + + onControllerMouseUp() { + this.onControlEnd(this._propPath); + } + + updateDataFromController() { + if (this._controller.updated && this.target) { + this.onControlUpdate(this._propPath); + const deltaSize = this._controller.getDeltaSize(); + // size 为半长:手柄位移即半长增量,除以世界缩放换算到本地,不乘 2 + Vec3.divide(deltaSize, deltaSize, this._scale); + const newSize = Vec3.add(tempVec3, this._size, deltaSize); + newSize.x = Math.max(0, newSize.x); + newSize.y = Math.max(0, newSize.y); + newSize.z = Math.max(0, newSize.z); + this.target.size = newSize; + this.onComponentChanged(this.target.node); + } + } + + updateControllerTransform() { + this.updateControllerData(); + } + + updateControllerData() { + if (!this._isInitialized || this.target == null) return; + if (this.target instanceof ReflectionProbe) { + const node = this.target.node; + this._controller.show(); + this._controller.checkEdit(); + const worldScale = node.getWorldScale(); + const worldPos = node.getWorldPosition(); + const worldRot = tempQuat_a; + node.getWorldRotation(worldRot); + this._controller.setScale(worldScale); + this._controller.setPosition(worldPos); + this._controller.setRotation(worldRot); + // 影响盒中心即节点原点,全尺寸 = 半长 * 2 + const fullSize = Vec3.multiplyScalar(tempVec3, this.target.size, 2); + this._controller.updateSize(Vec3.ZERO, fullSize); + } else { + this._controller.hide(); + } + } + + onTargetUpdate() { + this.updateControllerData(); + } + + onNodeChanged() { + this.updateControllerData(); + } +} + +export const name = js.getClassName(ReflectionProbe); +// 仅选中 ReflectionProbe 节点时显示影响盒(对齐 Creator 的选中态;图标/预览球后续再加)。 +export const SelectGizmo = ReflectionProbeComponentGizmo; +export const IconGizmo = null; +export const PersistentGizmo = null; + +registerGizmo(name, { SelectGizmo }); diff --git a/src/core/scene/scene-process/service/gizmo/components/skinned-mesh-renderer/index.ts b/src/core/scene/scene-process/service/gizmo/components/skinned-mesh-renderer/index.ts index 15d7324e3..84d92a236 100644 --- a/src/core/scene/scene-process/service/gizmo/components/skinned-mesh-renderer/index.ts +++ b/src/core/scene/scene-process/service/gizmo/components/skinned-mesh-renderer/index.ts @@ -3,6 +3,7 @@ import { js, SkinnedMeshRenderer, Vec3 } from 'cc'; import GizmoBase from '../../base/gizmo-base'; import BoxController from '../../controller/box'; +import { LightProbeTetraHelper } from '../../utils/light-probe-tetra'; import { registerGizmo } from '../../gizmo-defines'; const tempSize = new Vec3(); @@ -10,10 +11,12 @@ const tempCenter = new Vec3(); class SkinningModelComponentGizmo extends GizmoBase { private _controller!: BoxController; + private _tetraHelper!: LightProbeTetraHelper; init() { this._controller = new BoxController(this.getGizmoRoot()); this._controller.setOpacity(150); + this._tetraHelper = new LightProbeTetraHelper(this.getGizmoRoot()); this._isInitialized = true; } @@ -24,6 +27,7 @@ class SkinningModelComponentGizmo extends GizmoBase { onHide() { this._controller.hide(); + this._tetraHelper.hide(); } updateControllerData() { @@ -34,6 +38,7 @@ class SkinningModelComponentGizmo extends GizmoBase { const rootBoneNode = this.target.skinningRoot; if (!rootBoneNode) { this._controller.hide(); + this._tetraHelper.hide(); return; } @@ -45,6 +50,9 @@ class SkinningModelComponentGizmo extends GizmoBase { } else { this._controller.hide(); } + + // 影响该物体的光照探针四面体连线(仅当开启“使用光照探针”时显示) + this._tetraHelper.update(this.target); } updateControllerTransform() { @@ -62,6 +70,15 @@ class SkinningModelComponentGizmo extends GizmoBase { onUpdate() { this.updateControllerData(); } + + onLightProbeChanged() { + this._tetraHelper.invalidate(); + if (this.target) this._tetraHelper.update(this.target); + } + + onDestroy() { + this._tetraHelper?.destroy(); + } } export const name = js.getClassName(SkinnedMeshRenderer); diff --git a/src/core/scene/scene-process/service/gizmo/utils/engine-utils.ts b/src/core/scene/scene-process/service/gizmo/utils/engine-utils.ts index b2ce1d1f6..bf09f75ca 100644 --- a/src/core/scene/scene-process/service/gizmo/utils/engine-utils.ts +++ b/src/core/scene/scene-process/service/gizmo/utils/engine-utils.ts @@ -319,6 +319,28 @@ export function setMaterialProperty(node: Node, propName: string, value: any) { setNodeMaterialProperty(node, propName, value); } +/** + * 设置光照探针可视化材质的 SH 系数(对应 internal/editor/light-probe-visualization 的 Constant uniform)。 + * 移植自 Cocos Creator EngineUtils.setMeshSHCoefficients。 + */ +export function setMeshSHCoefficients(node: Node, coefficients: Float32Array) { + const value = new Vec4(); + const names = [ + 'cc_sh_linear_const_r', + 'cc_sh_linear_const_g', + 'cc_sh_linear_const_b', + 'cc_sh_quadratic_r', + 'cc_sh_quadratic_g', + 'cc_sh_quadratic_b', + 'cc_sh_quadratic_a', + ]; + for (let i = 0; i < names.length; i++) { + const offset = i * 4; + value.set(coefficients[offset], coefficients[offset + 1], coefficients[offset + 2], coefficients[offset + 3]); + setNodeMaterialProperty(node, names[i], value); + } +} + export function getModel(node: Node) { return node.getComponent(MeshRenderer); } diff --git a/src/core/scene/scene-process/service/gizmo/utils/light-probe-tetra.ts b/src/core/scene/scene-process/service/gizmo/utils/light-probe-tetra.ts new file mode 100644 index 000000000..48cf112ef --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/utils/light-probe-tetra.ts @@ -0,0 +1,201 @@ +'use strict'; + +import { Color, Node, Vec3, pipeline, SH } from 'cc'; +import ControllerUtils from './controller-utils'; +import { addMeshToNode, create3DNode, getModel, setMeshColor, setMeshSHCoefficients } from './engine-utils'; + +// 对齐 Cocos Creator controller-light-probe-tetrahedron.ts 的颜色 +const TETRA_PROBE_COLOR = new Color(172, 237, 207); // #ACEDCF +const TETRA_LINE_COLOR = new Color(206, 246, 226); // #CEF6E2 + +// 内部四面体 6 条边(顶点 0..3) +const TETRAHEDRON_LINES = [0, 1, 0, 2, 0, 3, 1, 2, 1, 3, 2, 3]; +// 外部胞元:3 条三角形边 + 3 条顶点法线(顶点 0..2,法线端点 3..5) +const OUTER_CELL_LINES = [0, 1, 0, 2, 1, 2, 0, 3, 1, 4, 2, 5]; + +const tempScale = new Vec3(); + +/** + * 影响当前所选物体的光照探针四面体高亮(忠实移植自 Cocos Creator + * `controller-light-probe-tetrahedron.ts`): + * 选中开启“使用光照探针”的 MeshRenderer/SkinnedMeshRenderer 时,用引擎已算好的 + * `model.tetrahedronIndex` 取其所在四面体,画出该四面体的探针小球(SH 受光,effect + * `internal/editor/light-probe-visualization`)+ 连线。 + */ +export class LightProbeTetraHelper { + private _root: Node | null; + private _container: Node | null = null; + private _spheres: Node[] = []; + private _lineNode: Node | null = null; + private _shData = new Float32Array(pipeline.UBOSH.COUNT); + private _lastSig = ''; // 上次渲染的签名,未变化时跳过 SH/连线重建 + + constructor(root: Node | null) { + this._root = root; + } + + hide(): void { + if (this._container) this._container.active = false; + this._lastSig = ''; + } + + /** 使缓存失效:下次 update 强制重建(用于探针数据/烘焙/设置变化,覆盖所有 SH 系数与法线等输入)。 */ + invalidate(): void { + this._lastSig = ''; + } + + destroy(): void { + if (this._container) { + this._container.destroy(); + this._container = null; + this._spheres.length = 0; + this._lineNode = null; + } + } + + /** + * @param comp MeshRenderer 或 SkinnedMeshRenderer(含 bakeSettings / node / model) + */ + update(comp: any): void { + if (!this._root || !comp) { + this.hide(); + return; + } + const model = comp.model; + // 引擎判断该模型是否使用探针(等价 bakeSettings.useLightProbe 且已参与探针) + if (!model || !(model.showTetrahedron?.() ?? false)) { + this.hide(); + return; + } + const tetIndex: number = model.tetrahedronIndex; + if (tetIndex < 0) { + this.hide(); + return; + } + + const globals = (comp.node as Node | undefined)?.scene?.globals; + const lightProbeInfo = (globals as any)?.lightProbeInfo; + const data = lightProbeInfo?.data; + if (!data || data.empty()) { + this.hide(); + return; + } + const vertices = data.probes; + const tetrahedrons = data.tetrahedrons; + if (!vertices || vertices.length === 0 || tetIndex >= tetrahedrons.length) { + this.hide(); + return; + } + + const tet = tetrahedrons[tetIndex]; + const reduceRinging: number = lightProbeInfo.reduceRinging ?? 0; + const volume: number = lightProbeInfo.lightProbeSphereVolume ?? 1.0; + const isInner: boolean = tet.isInnerTetrahedron(); + + const indices = [tet.vertex0, tet.vertex1, tet.vertex2]; + if (isInner) indices.push(tet.vertex3); + + // 签名:四面体索引 + 体积 + reduceRinging + 各顶点位置/SH 首系数。未变化时跳过重建。 + // 其余 SH 系数、法线等在探针数据/烘焙/设置变化时通过 invalidate() 失效缓存来覆盖。 + let sig = `${tetIndex}|${volume}|${reduceRinging}|${isInner ? 1 : 0}`; + for (const idx of indices) { + const p = vertices[idx].position; + sig += `|${p.x},${p.y},${p.z}`; + const c = vertices[idx].coefficients; + if (c && c.length > 0) { + const c0 = c[0]; + sig += `:${c0.x},${c0.y},${c0.z}`; + } + } + + this._ensureNodes(); + if (!this._container) return; + if (sig === this._lastSig && this._container.active) return; + this._lastSig = sig; + this._container.active = true; + + // 探针小球(世界固定尺寸 = volume * 0.06 * 半径5) + const scale = volume * 0.06; + tempScale.set(scale, scale, scale); + // 第 4 个球只在内部四面体时显示 + this._spheres[3].active = isInner; + for (let i = 0; i < indices.length; i++) { + const idx = indices[i]; + const sphere = this._spheres[i]; + sphere.active = true; + sphere.setWorldPosition(vertices[idx].position); + sphere.setWorldScale(tempScale); + + const coeff = vertices[idx].coefficients; + if (coeff && coeff.length > 0) { + const c = coeff.slice(); + SH.reduceRinging(c, reduceRinging); + SH.updateUBOData(this._shData, pipeline.UBOSH.SH_LINEAR_CONST_R_OFFSET, c); + } else { + this._shData.fill(0); + } + setMeshSHCoefficients(sphere, this._shData); + } + + // 连线(世界坐标 → 线节点单位阵) + if (this._lineNode) { + if (isInner) { + ControllerUtils.drawLines( + this._lineNode, + indices.map((i) => vertices[i].position), + TETRAHEDRON_LINES, + TETRA_LINE_COLOR, + ); + } else { + const positions: Vec3[] = indices.map((i) => vertices[i].position.clone()); + for (const i of indices) { + positions.push(vertices[i].position.clone().add(vertices[i].normal)); + } + ControllerUtils.drawLines(this._lineNode, positions, OUTER_CELL_LINES, TETRA_LINE_COLOR); + } + this._lineNode.active = true; + this._lineNode.setWorldPosition(0, 0, 0); + this._lineNode.setRotationFromEuler(0, 0, 0); + this._lineNode.setWorldScale(1, 1, 1); + } + } + + private _ensureNodes(): void { + if (this._container || !this._root) return; + this._container = create3DNode('LightProbeTetra'); + this._container.parent = this._root; + + const opts = { + instancing: false, + depthTestForTriangles: true, + effectName: 'internal/editor/light-probe-visualization', + technique: 0, + useLightProbe: true, + } as any; + + let reuseMesh: any = null; + for (let i = 0; i < 4; i++) { + let node: Node; + if (i === 0) { + node = ControllerUtils.sphere(Vec3.ZERO, 5, TETRA_PROBE_COLOR, opts); + reuseMesh = getModel(node)?.mesh; + } else { + node = create3DNode(); + if (reuseMesh) { + // 复用 mesh,但每球独立材质(各自 SH 系数) + addMeshToNode(node, reuseMesh, opts); + setMeshColor(node, TETRA_PROBE_COLOR); + } else { + node = ControllerUtils.sphere(Vec3.ZERO, 5, TETRA_PROBE_COLOR, opts); + } + } + node.parent = this._container; + node.active = false; + this._spheres.push(node); + } + + this._lineNode = create3DNode('LightProbeTetraLines'); + this._lineNode.parent = this._container; + this._lineNode.active = false; + } +} diff --git a/src/core/scene/scene-process/service/node/index.ts b/src/core/scene/scene-process/service/node/index.ts index 91a588197..b74174f4a 100644 --- a/src/core/scene/scene-process/service/node/index.ts +++ b/src/core/scene/scene-process/service/node/index.ts @@ -173,6 +173,7 @@ export class NodeManager { [Node.EventType.CHILD_ADDED]: 'onNodeParentChanged', [Node.EventType.CHILD_REMOVED]: 'onNodeParentChanged', [Node.EventType.LIGHT_PROBE_CHANGED]: 'onLightProbeChanged', + [Node.EventType.LIGHT_PROBE_BAKING_CHANGED]: 'onLightProbeBakingChanged', } as const; private nodeHandlers = new Map(); @@ -286,6 +287,11 @@ export class NodeManager { this.emit('node:change', node, changeOpts); } + onLightProbeBakingChanged(node: Node) { + const changeOpts: IChangeNodeOptions = { type: NodeEventType.LIGHT_PROBE_BAKING_CHANGED, source: EventSourceType.ENGINE }; + this.emit('node:change', node, changeOpts); + } + /** * 清空当前管理的节点 */ diff --git a/src/core/scene/scene-process/service/public/event-enum.ts b/src/core/scene/scene-process/service/public/event-enum.ts index d8023debb..320cd52c6 100644 --- a/src/core/scene/scene-process/service/public/event-enum.ts +++ b/src/core/scene/scene-process/service/public/event-enum.ts @@ -11,6 +11,7 @@ enum NodeEventType { NOTIFY_NODE_CHANGED = 'notify-node-changed', PREFAB_INFO_CHANGED = 'prefab-info-changed', // prefab数据改变 LIGHT_PROBE_CHANGED = 'light-probe-changed', // 光照探针数据改变 + LIGHT_PROBE_BAKING_CHANGED = 'light-probe-baking-changed', // 光照探针烘焙数据改变 } enum NodeOperationType { diff --git a/src/core/scene/test/gizmo-reload-events.test.ts b/src/core/scene/test/gizmo-reload-events.test.ts index 944321700..a22843584 100644 --- a/src/core/scene/test/gizmo-reload-events.test.ts +++ b/src/core/scene/test/gizmo-reload-events.test.ts @@ -127,6 +127,8 @@ jest.mock('../scene-process/service/gizmo/components/mesh-renderer', () => ({})) jest.mock('../scene-process/service/gizmo/components/skinned-mesh-renderer', () => ({})); jest.mock('../scene-process/service/gizmo/components/video-player', () => ({})); jest.mock('../scene-process/service/gizmo/components/web-view', () => ({})); +jest.mock('../scene-process/service/gizmo/components/light-probe-group', () => ({})); +jest.mock('../scene-process/service/gizmo/components/reflection-probe', () => ({})); describe('Gizmo editor lifecycle', () => { afterEach(() => { 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 6767d9627..139c3e837 100644 --- a/src/core/scene/test/service-core/message-callsite.test.ts +++ b/src/core/scene/test/service-core/message-callsite.test.ts @@ -162,6 +162,8 @@ jest.mock('../../scene-process/service/gizmo/components/mesh-renderer', () => ({ jest.mock('../../scene-process/service/gizmo/components/skinned-mesh-renderer', () => ({})); jest.mock('../../scene-process/service/gizmo/components/video-player', () => ({})); jest.mock('../../scene-process/service/gizmo/components/web-view', () => ({})); +jest.mock('../../scene-process/service/gizmo/components/light-probe-group', () => ({})); +jest.mock('../../scene-process/service/gizmo/components/reflection-probe', () => ({})); jest.mock('../../scene-process/service/dump', () => ({ __esModule: true,