Feat/light probe gizmos#761
Conversation
Add select gizmos for light probe and reflection probe components in the scene editor: - LightProbeGroup: draw all probes, the inner tetrahedron wireframe, and an editable generation bounding box (per-face min/max editing). - Mesh renderers that use light probes: highlight the influencing tetrahedron with SH-lit probe spheres and connecting edges, driven by the engine's model tetrahedron index. - ReflectionProbe: draw the influence bounding box. Add setMeshSHCoefficients to the gizmo engine utils for the light-probe-visualization material, and register the new component gizmos.
- Store the editor camera view per scene (position, rotation, view center, plus 2D content rect and scale) and restore it when the scene reopens. - Provide empty defaults for camera-infos/camera-uuids so reading them on first open no longer fails. - Improve focus range calculation for mesh, skinned mesh, particle and light probe group nodes.
| @@ -178,15 +190,16 @@ export class CameraService extends BaseService<ICameraEvents> implements ICamera | |||
| if (config.fov !== undefined) this.setCameraProperty({ fov: config.fov }, false); | |||
| if (config.far !== undefined) { | |||
| this._controller3D.far = config.far; | |||
There was a problem hiding this comment.
[P1] Apply near/far values to the actual Camera
This only updated the controller _near/_far fields, while the CameraControllerBase setter only updates the field and does not update this._camera. Because initFromConfig() runs after the controller is active, the configured near/far values would not reach the render camera (the new 2D far2D/near2D values had the same issue) until the controller was reactivated. Please keep the _camera.near/far assignment or route this through setCameraProperty.
There was a problem hiding this comment.
Fixed. _applyConfig now also assigns near/far (and near2D/far2D) to the active _camera, not just the controller fields, guarded by the current dimension — restored the removed _camera.near/far assignment and added the 2D equivalents.
|
|
||
| try { | ||
| const rpc = Rpc.getInstance(); | ||
| const cameraInfos = await rpc.request('sceneConfigInstance', 'get', ['camera-infos']); |
There was a problem hiding this comment.
[P1] Reload camera infos for every opened scene
initFromConfig() was only called from the if (!this._camera) branch (the original line 103), but _cameraInfos is stored in sceneConfigInstance by scene UUID. When CameraService is reused, opening a second scene would skip this load and keep the first scene _cameraInfos, so the camera view could be restored incorrectly or not restored at all. Please load camera-infos/camera-uuids on every onEditorOpened, or reload them for the current scene UUID.
There was a problem hiding this comment.
Fixed. Extracted loadCameraInfos() and call it on every onEditorOpened (awaited before defaultFocus), so a reused CameraService reloads camera-infos/camera-uuids per scene open instead of keeping the first scene's data.
| let range = 0; | ||
| if (component.shapeModule?.enable) { | ||
| const shapeModule = component.shapeModule; | ||
| const ShapeType = (cc as any).ShapeType; |
There was a problem hiding this comment.
[P2] Use the correct particle shape enum
The engine defines ParticleShapeType (packages/engine/cocos/particle/enum.ts) and does not expose cc.ShapeType. At runtime this therefore resolved to undefined, so the entire switch was skipped and particle focus ranges did not reflect the emitter size. Please use the available enum or the corresponding constants.
There was a problem hiding this comment.
Fixed. cc.ShapeType does not exist and ParticleShapeType is not exported from cc, so the switch was dead. Replaced it with a local ShapeType enum (Box/Circle/Cone/Sphere/Hemisphere), matching how Creator does it in utils/node.ts.
- _applyConfig now also applies near/far (and 2D near2D/far2D) to the active editor camera, not only the controller fields, so scene config takes effect on the render camera immediately after open. - Reload camera-infos/camera-uuids on every scene open (moved out of the first-camera-init path) so a reused CameraService restores the correct per-scene view instead of keeping the first scene's data. - Fix particle focus-range: cc.ShapeType does not exist, which left the switch dead and the range at its default. Use a local ShapeType enum (matching Creator's utils/node.ts) instead.
a25fe29 to
d228848
Compare
| 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 < 4) return; |
There was a problem hiding this comment.
[P2] Preserve support for RGB grid colors
The public method accepts number[], and the previous implementation accepted a three-component [r, g, b] value by defaulting alpha. This early return now silently ignores any RGB-only caller or older config, so the grid color is not applied. Please accept at least three components and default a to 255, or narrow the public contract and migrate existing callers.
There was a problem hiding this comment.
Fixed. The guard now only returns for arrays shorter than 3; a 3-component RGB value is accepted with alpha defaulting to 255, so RGB-only callers and older configs still apply.
| if (comp.maxPos && comp.minPos) { | ||
| const probesSize = new Vec3(); | ||
| Vec3.subtract(probesSize, comp.maxPos, comp.minPos); | ||
| compRange = Math.max(Math.abs(probesSize.x / 2), Math.abs(probesSize.y / 2), Math.abs(probesSize.z / 2)); |
There was a problem hiding this comment.
[P2] Account for the node world scale when calculating LightProbeGroup focus range
minPos/maxPos are local-space bounds, but this value is used as a world-space focus radius and no node transform is applied here. A scaled LightProbeGroup will therefore be focused too closely and may be clipped. Please multiply by the absolute world scale (or derive the range from a world-space bound), consistent with the mesh path.
There was a problem hiding this comment.
Fixed. The LightProbeGroup range now multiplies the local minPos/maxPos half-extents by the node world scale, so a scaled group is framed with the correct world-space radius, consistent with the mesh path.
- setGridColor: accept 3-component RGB values (default alpha to 255) instead of ignoring any color array shorter than 4, so RGB-only callers and older configs still apply. - LightProbeGroup focus range: multiply the local minPos/maxPos half-extents by the node world scale, so a scaled group is framed with the correct world-space radius (consistent with the mesh path).
| break; | ||
| } | ||
| case 'cc.ParticleSystem': | ||
| compRange = getRangeFromParticleComp(component); |
There was a problem hiding this comment.
[P2] Convert particle bounds to world space
getRangeFromParticleComp() returns emitter dimensions in the component local space, but maxRange is used to compute a world-space camera distance. Unlike the mesh and LightProbeGroup paths, this branch ignores node.getWorldScale(). A scaled ParticleSystem will therefore still be focused too closely. Please apply the absolute world scale (and account for rotation if needed) before assigning compRange.
There was a problem hiding this comment.
Fixed. The ParticleSystem branch now multiplies the local-space range from getRangeFromParticleComp by the node absolute world scale (max of |ws.x|,|ws.y|,|ws.z|), consistent with the mesh and LightProbeGroup paths, so a scaled ParticleSystem is framed correctly.
getRangeFromParticleComp returns emitter dimensions in the component's local space, but the value is used as a world-space focus radius. Like the mesh and LightProbeGroup paths, multiply by the node's absolute world scale so a scaled ParticleSystem is framed correctly.
| case ShapeType.Circle: | ||
| case ShapeType.Sphere: | ||
| case ShapeType.Hemisphere: | ||
| range = shapeModule.radius; |
There was a problem hiding this comment.
[P2] Include ShapeModule scale in the particle range
ShapeModule.scale is applied by the engine to every emitter shape through its transform matrix, but this helper only uses it for Box. Sphere, hemisphere, circle, and cone ranges ignore a non-unit shapeModule.scale, so a scaled emitter can still be focused too closely even after the node world-scale fix. Please apply the absolute shape scale to these branches as well (using a conservative maximum axis scale if appropriate).
There was a problem hiding this comment.
Fixed. getRangeFromParticleComp now applies shapeModule.scale to sphere/circle/hemisphere/cone as well (multiplying radius/length by the max absolute axis scale), not only Box, so a scaled emitter is framed correctly.
| this._controller2D.near = config.near2D; | ||
| if (this._camera && this.is2D) this._camera.near = config.near2D; | ||
| } | ||
| if (config.wheelSpeed2D !== undefined) this._controller2D.wheelSpeed = config.wheelSpeed2D; |
There was a problem hiding this comment.
[P1] Persist the new 2D camera settings
_applyConfig() accepts far2D, near2D, and wheelSpeed2D, but queryConfig() still returns only the 3D far/near/wheelSpeed values. Since _saveConfig() serializes queryConfig(), calls such as updateConfig({ far2D: ..., near2D: ..., wheelSpeed2D: ... }) and the 2D reset values are lost on the next reload. Please include all three 2D fields in queryConfig() and keep the 3D values separate from the active camera when needed.
There was a problem hiding this comment.
Fixed. queryConfig() now returns far2D/near2D/wheelSpeed2D (read from the 2D controller) and reads the 3D near/far from _controller3D instead of the active camera, so 2D settings survive save/reload.
| dot = ControllerUtils.sphere(Vec3.ZERO, PROBE_SPHERE_BASE_RADIUS, PROBE_COLOR, { depthTestForTriangles: true }); | ||
| this._reuseMesh = getModel(dot)?.mesh; | ||
| } else { | ||
| dot = ControllerUtils.sphere(Vec3.ZERO, PROBE_SPHERE_BASE_RADIUS, PROBE_COLOR, { depthTestForTriangles: true }, undefined); |
There was a problem hiding this comment.
[P2] Actually reuse the probe sphere mesh
_reuseMesh is only used as a boolean here. The second branch calls ControllerUtils.sphere(...) again, which creates a new mesh; its optional fifth argument is a material, not a mesh. A 4096-probe group therefore allocates a separate sphere mesh for every probe, defeating the purpose of MAX_PROBE_DOTS. Please use addMeshToNode(node, _reuseMesh, opts) or provide a sphere helper that reuses the mesh.
There was a problem hiding this comment.
Fixed. Probe dots now reuse the first sphere mesh via addMeshToNode(node, _reuseMesh, opts) (+ setMeshColor) instead of calling ControllerUtils.sphere again, so a large group no longer allocates a mesh per probe.
| if (!showProbe) return; | ||
|
|
||
| const probes = this.target.probes; | ||
| if (!force && probes === this._probesRef) return; |
There was a problem hiding this comment.
[P2] Invalidate the dot cache when global probe display settings change
The cache checks only probes === _probesRef, but sphere size depends on lightProbeSphereVolume; showProbe, showWireframe, and the tetrahedron data also live on scene.globals.lightProbeInfo and can change without replacing target.probes. The selected gizmo can therefore keep stale size, visibility, or geometry until it is reselected. Include the relevant global settings/data in the cache key and refresh on their changes.
There was a problem hiding this comment.
Fixed. The dot cache is now keyed on both the probe array and lightProbeSphereVolume, and an onUpdate signature (volume, showProbe, showWireframe, tetrahedron/probe counts) refreshes the gizmo when those global settings/data change without reselecting.
| this.updateControllerData(); | ||
| } | ||
|
|
||
| onUpdate() { |
There was a problem hiding this comment.
[P2] Avoid rebuilding SH geometry every frame
onUpdate calls updateControllerData() every frame; _tetraHelper.update() then recomputes SH, writes seven material uniforms per sphere, and calls drawLines to rebuild the dynamic mesh even when the model tetrahedron index, probe data, coefficients, and display scale are unchanged. Selecting a mesh therefore incurs continuous allocations and GPU updates. Cache a signature and update only when the tetrahedron/data/coefficients or required transform changes.
There was a problem hiding this comment.
Fixed. LightProbeTetraHelper.update() now builds a signature (tetrahedron index, volume, vertex positions, SH first coefficient) and returns early when unchanged, so the SH uniform writes and drawLines rebuild no longer run every frame. mesh-renderer onUpdate also only refreshes the tetrahedron now, not the whole box.
There was a problem hiding this comment.
Follow-up: to fully match Creator, removed the per-frame onUpdate from the MeshRenderer gizmo so the influencing tetrahedron is now purely event-driven (show/target/node changes), giving zero per-frame cost — Creator's mesh-renderer gizmo has no onUpdate either. SkinnedMeshRenderer keeps onUpdate for animated bounds (same as Creator), where the signature cache still prevents redundant SH/line rebuilds.
There was a problem hiding this comment.
Also wired light-probe change refresh so the event-driven mesh tetrahedron stays correct: the LIGHT_PROBE_CHANGED node event (emitted on the LightProbeGroup node when it regenerates) is now fanned out to the selected MeshRenderer/SkinnedMeshRenderer gizmos via onLightProbeChanged(), so their influencing tetrahedron refreshes without reselecting. The signature cache makes the repeated notifications cheap.
- queryConfig() now returns far2D/near2D/wheelSpeed2D and reads the 3D near/far from the 3D controller (not the active camera), so 2D camera settings persist across save/reload. - LightProbeGroup probe dots now reuse the sphere mesh via addMeshToNode instead of allocating a new mesh per probe, and the dot cache is keyed on the probe array and lightProbeSphereVolume; an onUpdate signature (volume/showProbe/showWireframe/data) refreshes on global changes. - LightProbeTetraHelper caches a signature (tetrahedron index, volume, vertex positions, SH coefficients) and skips the SH uniform writes and dynamic-mesh rebuild when unchanged. mesh-renderer onUpdate now only refreshes the tetrahedron instead of rebuilding the whole box each frame.
Remove the per-frame onUpdate from the MeshRenderer gizmo; the influencing tetrahedron now refreshes only on show/target/node changes, matching Creator's mesh-renderer gizmo (which has no onUpdate) for zero per-frame cost. SkinnedMeshRenderer keeps onUpdate for animated bounds (as Creator does), and the tetra helper's signature cache still avoids redundant SH/line rebuilds there.
When a LightProbeGroup regenerates, the engine emits LIGHT_PROBE_CHANGED on the group node, which cli surfaces as a node change. Fan that event out to the selected MeshRenderer/SkinnedMeshRenderer gizmos so their influencing-tetrahedron highlight refreshes even when the mesh node itself did not move (previously it only updated on reselect). The tetra helper's signature cache keeps repeated notifications cheap.
| info ? (info.lightProbeSphereVolume ?? 1) : 1, | ||
| info ? (info.showProbe ?? true) : true, | ||
| info ? (info.showWireframe ?? true) : true, | ||
| data?.tetrahedrons?.length ?? 0, |
There was a problem hiding this comment.
[P2] Include probe data contents in the LightProbeGroup refresh signature
_computeInfoSig() only uses probe/tetrahedron counts. Regenerating or rebaking probes with the same counts leaves this signature unchanged, so onUpdate() skips updateControllerData() and the selected gizmo keeps stale probe positions and wireframe edges. Please include the relevant positions/indices or invalidate the signature from the light-probe-changed event.
There was a problem hiding this comment.
Fixed. The LightProbeGroup gizmo now implements onLightProbeChanged() which clears its dot/info caches and re-runs updateControllerData, so regenerate/rebake with unchanged probe/tetrahedron counts still refreshes positions and wireframe instead of being skipped by the count-only signature.
| if (isInner) indices.push(tet.vertex3); | ||
|
|
||
| // 签名:四面体索引 + 体积 + 各顶点位置/SH 首系数。未变化时跳过昂贵的 SH 与连线重建。 | ||
| let sig = `${tetIndex}|${volume}|${isInner ? 1 : 0}`; |
There was a problem hiding this comment.
[P2] Include all SH inputs in the tetrahedron cache signature
The signature includes only the first SH coefficient for each vertex. It omits reduceRinging, coefficients 1 through 8, and the vertex normals used to draw outer-cell edges. A rebake or settings change that preserves positions and coefficient 0 will therefore be ignored and the visualization will remain stale. Please include all render inputs or invalidate the cache on every light-probe data/settings change.
There was a problem hiding this comment.
Fixed. Added reduceRinging to the signature and an invalidate() on the helper that is called on light-probe changes (via onLightProbeChanged), forcing a full rebuild so all SH coefficients, vertex normals and settings are picked up rather than only coefficient 0.
| } | ||
|
|
||
| // 探针数据变化(探针组重生成等)时刷新影响四面体;本节点未移动也需响应 | ||
| onLightProbeChanged() { |
There was a problem hiding this comment.
[P2] Keep a trigger for MeshRenderer tetrahedron-index changes
Before this removal, onUpdate() was the only path that observed model.tetrahedronIndex changes made by the engine update loop. The new onLightProbeChanged() path covers global probe-data changes, but it does not run when a selected MeshRenderer moves or animates across tetrahedra without a node-change event. The helper can therefore remain cached on the old tetrahedron. Please retain a cheap per-frame index check or emit a model change event from the engine.
There was a problem hiding this comment.
Fixed. Re-added a minimal per-frame check in the MeshRenderer gizmo onUpdate that compares model.tetrahedronIndex and only refreshes when it changes, so movement/animation across tetrahedra (updated by the engine loop without a node event) is captured without rebuilding SH/lines every frame.
- Particle focus range now applies shapeModule.scale to sphere/circle/ hemisphere/cone emitters too (not only Box), via the max absolute axis scale. - Re-add a cheap per-frame tetrahedron-index check to the MeshRenderer gizmo so movement/animation across tetrahedra (updated by the engine loop without a node event) is captured; it only rebuilds when the index changes. - Invalidate the tetrahedron helper cache on light-probe changes (covers all SH coefficients, normals and settings, not just the first coefficient) and include reduceRinging in its signature. - Force-refresh the LightProbeGroup gizmo on light-probe changes by clearing its dot/info caches, so regenerate/rebake with unchanged counts no longer leaves stale probe positions or wireframe.
| // 引擎在渲染循环里更新 model.tetrahedronIndex(不触发节点事件),移动/动画跨四面体时需捕获。 | ||
| // 仅做一次索引整数比较,变化时才刷新,避免每帧重建 SH/连线。 | ||
| const idx = (this.target as any)?.model?.tetrahedronIndex ?? -1; | ||
| if (idx !== this._lastTetIndex) { |
There was a problem hiding this comment.
[P2] Continue polling the helper signature for display-setting changes
onUpdate() now calls _tetraHelper.update() only when tetrahedronIndex changes. However lightProbeSphereVolume and reduceRinging can change while the index stays the same, and their LightProbeInfo setters do not emit LIGHT_PROBE_CHANGED. The helper signature already includes these values, but it is never evaluated, so a selected MeshRenderer keeps stale sphere size or SH shading. Call update() each frame and rely on its cache, or track these settings in this fast path too.
There was a problem hiding this comment.
Fixed. MeshRenderer onUpdate now calls _tetraHelper.update() every frame and relies on its signature cache (which already includes tetrahedronIndex, volume and reduceRinging), instead of gating on tetrahedronIndex alone. So volume/reduceRinging changes that keep the index are now picked up; the cache still short-circuits when nothing changed.
| if (!node) return; | ||
| // 光照探针数据变化(如探针组重新生成)时,探针组自身节点会收到该事件, | ||
| // 但受其影响的 mesh 的四面体高亮挂在别的节点上,需主动通知选中的探针消费者刷新。 | ||
| if (opts?.type === NodeEventType.LIGHT_PROBE_CHANGED) { |
There was a problem hiding this comment.
[P2] Handle the engine baking-change event as well
The engine emits NodeEventType.LIGHT_PROBE_BAKING_CHANGED from LightProbeInfo.onProbeBakeFinished() and onProbeBakeCleared(), not LIGHT_PROBE_CHANGED. The Node service currently forwards only the latter, so this branch does not invalidate selected gizmos after a bake. A selected gizmo can show stale SH colors and tetrahedron data until it is refreshed or reselected. Please forward and handle LIGHT_PROBE_BAKING_CHANGED too (or invalidate directly from the bake event).
There was a problem hiding this comment.
Fixed. The Node service now forwards LIGHT_PROBE_BAKING_CHANGED (emitted by LightProbeInfo on bake finish/clear) as a node change, and the gizmo service handles it the same as LIGHT_PROBE_CHANGED (invalidating the tetra caches). Since the engine emits the bake event on every node in the scene, the refresh is debounced via a microtask so it runs once per bake.
- Forward the engine's LIGHT_PROBE_BAKING_CHANGED node event (emitted by LightProbeInfo on bake finish/clear) from the Node service, and handle it in the gizmo service alongside LIGHT_PROBE_CHANGED. The refresh is debounced via a microtask because the engine emits the bake event on every node in the scene. - MeshRenderer gizmo onUpdate now calls the tetra helper every frame and relies on its signature cache, instead of gating on tetrahedronIndex only. This catches lightProbeSphereVolume / reduceRinging changes, whose setters do not emit LIGHT_PROBE_CHANGED and leave the index unchanged.
knoxHuang
left a comment
There was a problem hiding this comment.
Code review approved. The latest fixes address the reported light-probe event and cache-refresh issues.
Summary
Two related scene-editor improvements.
Light probe / reflection probe gizmos
internal/editor/light-probe-visualizationmaterial.setMeshSHCoefficientsto the gizmo engine utils and registers the new component gizmos.Editor camera view persistence + focus
camera-infos/camera-uuidsso reading them on first open no longer throws.Verify
中文: 打开 sponza 场景,选中
Sphere-001(已开启"使用光照探针"),应显示影响它的光照探针四面体(薄荷绿受光小球 +连线)。
EN: Open the sponza scene and select
Sphere-001(with "Use Light Probe" enabled); the light probe tetrahedroninfluencing it should be displayed (mint-green shaded probe spheres + connecting lines).