Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/core/scene/common/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', // 设置节点上的属性
Expand Down
15 changes: 15 additions & 0 deletions src/core/scene/scene-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export interface ICameraConfig {
aperture: number;
shutter: number;
iso: number;
far2D?: number;
near2D?: number;
wheelSpeed2D?: number;
}

export interface IRectSnapConfig {
Expand Down Expand Up @@ -70,6 +73,15 @@ export interface ISceneConfig {
* SceneView 配置
*/
sceneView: ISceneViewConfig;
/**
* 各节点上编辑器相机的视角信息(按节点 uuid 存储),运行期由 Camera 服务写入。
* 提供空默认值以避免首次读取时配置层抛错。
*/
'camera-infos'?: Record<string, unknown>;
/**
* 记录过相机视角信息的节点 uuid 列表,运行期由 Camera 服务写入。
*/
'camera-uuids'?: string[];
}

class SceneConfig {
Expand Down Expand Up @@ -116,6 +128,9 @@ class SceneConfig {
sceneView: {
sceneLightOn: true,
},
// 运行期由 Camera 服务写入;提供空默认值,避免首次 get 时配置层抛错并被 RPC 中间件记为错误日志
'camera-infos': {},
'camera-uuids': [],
};

private configInstance!: IBaseConfiguration;
Expand Down
104 changes: 95 additions & 9 deletions src/core/scene/scene-process/service/camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class CameraService extends BaseService<ICameraEvents> implements ICamera
private _controllerFirstChange = false;
private _currentUuid = '';
private _cameraInfos: Record<string, any> = {};
private _cameraUuids: string[] = [];

get controller2D() { return this._controller2D; }
get controller3D() { return this._controller3D; }
Expand Down Expand Up @@ -113,8 +114,10 @@ export class CameraService extends BaseService<ICameraEvents> implements ICamera

this._detachSceneCameras();

setTimeout(() => {
setTimeout(async () => {
try {
// 每次打开场景都重新加载相机视角记忆,避免复用 CameraService 时取到上一个场景的数据
await this.loadCameraInfos();
this._controller.updateGrid();
this.defaultFocus(this._currentUuid);
Service.Engine.repaintInEditMode();
Expand Down Expand Up @@ -143,6 +146,22 @@ export class CameraService extends BaseService<ICameraEvents> implements ICamera
}
}

/**
* 加载相机视角记忆(按 scene UUID 存储)。每次打开场景都要重新加载,
* 因为 CameraService 会在多个场景间复用,只在首次创建相机时加载会导致后续场景取到旧数据。
*/
async loadCameraInfos(): Promise<void> {
try {
const rpc = Rpc.getInstance();
const cameraInfos = await rpc.request('sceneConfigInstance', 'get', ['camera-infos']);

@knoxHuang knoxHuang Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

const cameraUuids = await rpc.request('sceneConfigInstance', 'get', ['camera-uuids']);
this._cameraInfos = (cameraInfos as Record<string, any>) || {};
this._cameraUuids = (cameraUuids as string[]) || [];
} catch {
// camera-infos 不存在时使用默认空值
}
}

private _applyGizmoDisplay(config: Partial<IGizmoConfig>): void {
if (config.gridVisible !== undefined) this.setGridVisible(config.gridVisible, false);
if (config.gridColor !== undefined) this.setGridColor(config.gridColor);
Expand All @@ -152,11 +171,12 @@ export class CameraService extends BaseService<ICameraEvents> 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?.();
}

Expand All @@ -178,15 +198,24 @@ 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;

@knoxHuang knoxHuang Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

if (config.aperture !== undefined || config.shutter !== undefined || config.iso !== undefined) {
this.setCameraProperty({
aperture: config.aperture,
Expand Down Expand Up @@ -306,6 +335,7 @@ export class CameraService extends BaseService<ICameraEvents> 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] });
Expand All @@ -324,11 +354,15 @@ export class CameraService extends BaseService<ICameraEvents> 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,
Expand Down Expand Up @@ -382,6 +416,58 @@ export class CameraService extends BaseService<ICameraEvents> 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<void> {
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
Expand Down
122 changes: 115 additions & 7 deletions src/core/scene/scene-process/service/camera/camera-controller-3d.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;

Expand All @@ -53,23 +114,53 @@ 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':
compRange = 3;
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;
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
Loading
Loading