diff --git a/packages/@dcl/inspector/src/components/Renderer/Metrics/Metrics.tsx b/packages/@dcl/inspector/src/components/Renderer/Metrics/Metrics.tsx index fee4e4ca6..f7f1f10a5 100644 --- a/packages/@dcl/inspector/src/components/Renderer/Metrics/Metrics.tsx +++ b/packages/@dcl/inspector/src/components/Renderer/Metrics/Metrics.tsx @@ -1,16 +1,19 @@ import React, { useCallback, useEffect, useMemo } from 'react' import cx from 'classnames' import { IoGridOutline as SquaresGridIcon, IoAlertCircleOutline as AlertIcon } from 'react-icons/io5' +import { Material } from '@babylonjs/core' import { CrdtMessageType } from '@dcl/ecs' import { withSdk, WithSdkProps } from '../../../hoc/withSdk' import { useChange } from '../../../hooks/sdk/useChange' import { useOutsideClick } from '../../../hooks/useOutsideClick' +import { useAppDispatch, useAppSelector } from '../../../redux/hooks' +import { getMetrics, getLimits, setEntitiesOutOfBoundaries, setMetrics, setLimits } from '../../../redux/scene-metrics' +import { SceneMetrics } from '../../../redux/scene-metrics/types' import type { Layout } from '../../../lib/utils/layout' import { GROUND_MESH_PREFIX, PARCEL_SIZE } from '../../../lib/utils/scene' import { Button } from '../../Button' import { getSceneLimits } from './utils' -import type { Metrics } from './types' import './Metrics.css' @@ -49,35 +52,30 @@ const Metrics = withSdk(({ sdk }) => { const ROOT = sdk.engine.RootEntity const PLAYER_ROOT = sdk.engine.PlayerEntity const CAMERA_ROOT = sdk.engine.CameraEntity + const dispatch = useAppDispatch() + const metrics = useAppSelector(getMetrics) + const limits = useAppSelector(getLimits) const [showMetrics, setShowMetrics] = React.useState(false) - const [metrics, setMetrics] = React.useState({ - triangles: 0, - entities: 0, - bodies: 0, - materials: 0, - textures: 0 - }) const [sceneLayout, setSceneLayout] = React.useState({ base: { x: 0, y: 0 }, parcels: [] }) + const getNodes = useCallback( + () => + sdk.components.Nodes.getOrNull(ROOT)?.value.filter((node) => ![PLAYER_ROOT, CAMERA_ROOT].includes(node.entity)) ?? + [], + [sdk] + ) + const handleUpdateMetrics = useCallback(() => { const meshes = sdk.scene.meshes.filter( (mesh) => - !( - IGNORE_MESHES.includes(mesh.id) || - mesh.id.startsWith(GROUND_MESH_PREFIX) || - mesh.id.startsWith('BoundingMesh') - ) + !IGNORE_MESHES.includes(mesh.id) && + !mesh.id.startsWith(GROUND_MESH_PREFIX) && + !mesh.id.startsWith('BoundingMesh') ) const triangles = meshes.reduce((acc, mesh) => acc + mesh.getTotalVertices(), 0) - const entities = - ( - sdk.components.Nodes.getOrNull(ROOT)?.value.filter( - (node) => ![PLAYER_ROOT, CAMERA_ROOT].includes(node.entity) - ) ?? [ROOT] - ).length - 1 const uniqueTextures = new Set( sdk.scene.textures .filter((texture) => !IGNORE_TEXTURES.includes(texture.name)) @@ -86,30 +84,56 @@ const Metrics = withSdk(({ sdk }) => { const uniqueMaterials = new Set( sdk.scene.materials.map((material) => material.id).filter((id) => !IGNORE_MATERIALS.includes(id)) ) - setMetrics({ - triangles: triangles, - entities: entities, - bodies: meshes.length, - materials: uniqueMaterials.size, - textures: uniqueTextures.size - }) - }, [sdk]) + + dispatch( + setMetrics({ + triangles, + entities: getNodes().length, + bodies: meshes.length, + materials: uniqueMaterials.size, + textures: uniqueTextures.size + }) + ) + }, [sdk, dispatch, getNodes, setMetrics]) const handleUpdateSceneLayout = useCallback(() => { const scene = sdk.components.Scene.getOrNull(ROOT) if (scene) { - setSceneLayout({ ...(scene.layout as Layout) }) + setSceneLayout(scene.layout as Layout) + dispatch(setLimits(getSceneLimits(scene.layout.parcels.length))) } }, [sdk, setSceneLayout]) + const handleSceneChange = useCallback(() => { + const nodes = getNodes() + const entitiesOutOfBoundaries = nodes.reduce((count, node) => { + const entity = sdk.sceneContext.getEntityOrNull(node.entity) + return entity && entity.isOutOfBoundaries() ? count + 1 : count + }, 0) + + dispatch(setEntitiesOutOfBoundaries(entitiesOutOfBoundaries)) + }, [sdk, dispatch, getNodes, setEntitiesOutOfBoundaries]) + useEffect(() => { + const handleOutsideMaterialChange = (material: Material) => { + if (material.name === 'entity_outside_layout_multimaterial') { + handleSceneChange() + } + } + + const addOutsideMaterialObservable = sdk.scene.onNewMultiMaterialAddedObservable.add(handleOutsideMaterialChange) + const removeOutsideMaterialObservable = sdk.scene.onMaterialRemovedObservable.add(handleOutsideMaterialChange) + sdk.scene.onDataLoadedObservable.add(handleUpdateMetrics) sdk.scene.onMeshRemovedObservable.add(handleUpdateMetrics) + handleUpdateSceneLayout() return () => { sdk.scene.onDataLoadedObservable.removeCallback(handleUpdateMetrics) sdk.scene.onMeshRemovedObservable.removeCallback(handleUpdateMetrics) + sdk.scene.onNewMultiMaterialAddedObservable.remove(addOutsideMaterialObservable) + sdk.scene.onMaterialRemovedObservable.remove(removeOutsideMaterialObservable) } }, []) @@ -122,15 +146,10 @@ const Metrics = withSdk(({ sdk }) => { [handleUpdateSceneLayout] ) - const limits = useMemo(() => { - const parcels = sceneLayout.parcels.length - return getSceneLimits(parcels) - }, [sceneLayout]) - const limitsExceeded = useMemo>(() => { return Object.fromEntries( Object.entries(metrics) - .map(([key, value]) => [key, value > limits[key as keyof Metrics]]) + .map(([key, value]) => [key, value > limits[key as keyof SceneMetrics]]) .filter(([, value]) => value) ) }, [metrics, limits]) @@ -177,7 +196,7 @@ const Metrics = withSdk(({ sdk }) => {
{value} {'/'} - {limits[key as keyof Metrics]} + {limits[key as keyof SceneMetrics]}
))} diff --git a/packages/@dcl/inspector/src/lib/babylon/decentraland/EcsEntity.spec.ts b/packages/@dcl/inspector/src/lib/babylon/decentraland/EcsEntity.spec.ts index 88007af87..3b6336071 100644 --- a/packages/@dcl/inspector/src/lib/babylon/decentraland/EcsEntity.spec.ts +++ b/packages/@dcl/inspector/src/lib/babylon/decentraland/EcsEntity.spec.ts @@ -112,4 +112,96 @@ describe('EcsEntity', () => { expect(gltfPathLoading).toBe(filePath) expect(entity.isGltfPathLoading()).toBe(false) }) + + it('should dispose entity correctly', () => { + const Transform = components.Transform(engine) + entity.putComponent(Transform) + entity.boundingInfoMesh = new BABYLON.AbstractMesh('boundingInfoMesh', scene) + entity.dispose() + expect(entity.usedComponents.size).toBe(0) + expect(entity.boundingInfoMesh?.isDisposed()).toBe(true) + expect(scene.getTransformNodeByID(entity.id)).toBeNull() + }) + + it('should return true when gltfPathLoading is set', () => { + entity.setGltfPathLoading() + expect(entity.isGltfPathLoading()).toBe(true) + }) + + it('should return false when gltfPathLoading is not set', () => { + expect(entity.isGltfPathLoading()).toBe(false) + }) + + it('should resolve gltfPathLoading', async () => { + entity.setGltfPathLoading() + const filePath = 'some-path' + setTimeout(() => entity.resolveGltfPathLoading(filePath), 1) + expect(entity.isGltfPathLoading()).toBe(true) + const gltfPathLoading = await entity.getGltfPathLoading() + expect(gltfPathLoading).toBe(filePath) + expect(entity.isGltfPathLoading()).toBe(false) + }) + + it('should set gltfAssetContainer correctly', async () => { + const gltfAssetContainer = new BABYLON.AssetContainer(scene) + entity.setGltfAssetContainer(gltfAssetContainer) + expect(entity.gltfAssetContainer).toBe(gltfAssetContainer) + await expect(entity.onGltfContainerLoaded()).resolves.toBe(gltfAssetContainer) + }) + + it('should set gltfContainer correctly', async () => { + const gltfContainer = new BABYLON.AbstractMesh('gltfContainer', scene) + entity.setGltfContainer(gltfContainer) + expect(entity.gltfContainer).toBe(gltfContainer) + await expect(entity.onAssetLoaded()).resolves.toBe(gltfContainer) + }) + + it('should set meshRenderer correctly', async () => { + const meshRenderer = new BABYLON.AbstractMesh('meshRenderer', scene) + entity.setMeshRenderer(meshRenderer) + expect(entity.meshRenderer).toBe(meshRenderer) + await expect(entity.onAssetLoaded()).resolves.toBe(meshRenderer) + }) + + it('should set the visibility of the entity and its children', () => { + const gltfContainer = new BABYLON.AbstractMesh('gltfContainer', scene) + entity.setGltfContainer(gltfContainer) + const child = new EcsEntity(1 as Entity, context, scene) + const childMeshRenderer = new BABYLON.AbstractMesh('childMeshRenderer', scene) + child.parent = entity + child.setMeshRenderer(childMeshRenderer) + expect(entity.isHidden()).toBe(false) + expect(child.isHidden()).toBe(false) + entity.setVisibility(false) + expect(entity.isHidden()).toBe(true) + expect(child.isHidden()).toBe(true) + }) + + it('should set and get the lock state of the entity', () => { + expect(entity.isLocked()).toBe(false) + entity.setLock(true) + expect(entity.isLocked()).toBe(true) + }) + + it('should generate the bounding box correctly', () => { + const mesh1 = new BABYLON.Mesh('mesh1', scene) + const mesh2 = new BABYLON.Mesh('mesh2', scene) + mesh1.position = new BABYLON.Vector3(1, 1, 1) + mesh1.parent = entity + mesh2.position = new BABYLON.Vector3(2, 2, 2) + mesh2.parent = entity + entity.generateBoundingBox() + expect(entity.boundingInfoMesh).toBeDefined() + const boundingInfoMesh = entity.boundingInfoMesh! + expect(boundingInfoMesh.name).toBe(`BoundingMesh-${entity.id}`) + expect(boundingInfoMesh.position).toEqual(entity.absolutePosition) + expect(boundingInfoMesh.rotationQuaternion).toEqual(entity.absoluteRotationQuaternion) + expect(boundingInfoMesh.scaling).toEqual(entity.absoluteScaling) + expect(boundingInfoMesh.getBoundingInfo().boundingBox.minimumWorld).toEqual( + mesh1.getBoundingInfo().boundingBox.minimumWorld + ) + expect(boundingInfoMesh.getBoundingInfo().boundingBox.maximumWorld).toEqual( + mesh2.getBoundingInfo().boundingBox.maximumWorld + ) + }) }) diff --git a/packages/@dcl/inspector/src/lib/babylon/decentraland/EcsEntity.ts b/packages/@dcl/inspector/src/lib/babylon/decentraland/EcsEntity.ts index fde33081e..5cb3853b3 100644 --- a/packages/@dcl/inspector/src/lib/babylon/decentraland/EcsEntity.ts +++ b/packages/@dcl/inspector/src/lib/babylon/decentraland/EcsEntity.ts @@ -199,6 +199,10 @@ export class EcsEntity extends BABYLON.TransformNode { }) } } + + isOutOfBoundaries() { + return !!this.boundingInfoMesh?.showBoundingBox + } } /** @@ -244,22 +248,22 @@ async function validateEntityIsOutsideLayout(entity: EcsEntity) { function updateMeshBoundingBoxVisibility(entity: EcsEntity, mesh: BABYLON.AbstractMesh) { const scene = mesh.getScene() + if (scene.isLoading) return + const { isEntityOutsideLayout } = getLayoutManager(scene) if (isEntityOutsideLayout(mesh)) { if (mesh.showBoundingBox) return - + mesh.showBoundingBox = true for (const childMesh of entity.getChildMeshes(false)) { addOutsideLayoutMaterial(childMesh, scene) } - mesh.showBoundingBox = true } else { if (!mesh.showBoundingBox) return - + mesh.showBoundingBox = false for (const childMesh of entity.getChildMeshes(false)) { removeOutsideLayoutMaterial(childMesh) } - mesh.showBoundingBox = false } } diff --git a/packages/@dcl/inspector/src/lib/rpc/scene-metrics/client.ts b/packages/@dcl/inspector/src/lib/rpc/scene-metrics/client.ts new file mode 100644 index 000000000..39a0ac263 --- /dev/null +++ b/packages/@dcl/inspector/src/lib/rpc/scene-metrics/client.ts @@ -0,0 +1,20 @@ +import { RPC, Transport } from '@dcl/mini-rpc' +import { SceneMetricsRPC } from './types' + +export class SceneMetricsClient extends RPC { + constructor(transport: Transport) { + super(SceneMetricsRPC.name, transport) + } + + getMetrics = () => { + return this.request(SceneMetricsRPC.Method.GET_METRICS, undefined) + } + + getLimits = () => { + return this.request(SceneMetricsRPC.Method.GET_LIMITS, undefined) + } + + getEntitiesOutOfBoundaries = () => { + return this.request(SceneMetricsRPC.Method.GET_ENTITIES_OUT_OF_BOUNDARIES, undefined) + } +} diff --git a/packages/@dcl/inspector/src/lib/rpc/scene-metrics/scene-metrics.spec.ts b/packages/@dcl/inspector/src/lib/rpc/scene-metrics/scene-metrics.spec.ts new file mode 100644 index 000000000..b13b591b8 --- /dev/null +++ b/packages/@dcl/inspector/src/lib/rpc/scene-metrics/scene-metrics.spec.ts @@ -0,0 +1,88 @@ +import { InMemoryTransport } from '@dcl/mini-rpc' +import { SceneMetricsClient } from './client' +import { SceneMetricsServer } from './server' + +describe('SceneMetricsRPC', () => { + const parent = new InMemoryTransport() + const iframe = new InMemoryTransport() + + parent.connect(iframe) + iframe.connect(parent) + + const store = { + getState: jest.fn() + } + + const client = new SceneMetricsClient(parent) + const _server = new SceneMetricsServer(iframe, store) + + describe('When using the getMetrics method of the client', () => { + const metrics = { + triangles: 1, + entities: 1, + bodies: 1, + materials: 1, + textures: 1 + } + + beforeEach(() => { + store.getState.mockReturnValueOnce({ + sceneMetrics: { + metrics + } + }) + }) + + afterEach(() => { + store.getState.mockReset() + }) + + it('should get the metrics from the sceneMetrics state in the server', async () => { + await expect(client.getMetrics()).resolves.toBe(metrics) + }) + }) + + describe('When using the getLimits method of the client', () => { + const limits = { + triangles: 1, + entities: 1, + bodies: 1, + materials: 1, + textures: 1 + } + + beforeEach(() => { + store.getState.mockReturnValueOnce({ + sceneMetrics: { + limits + } + }) + }) + + afterEach(() => { + store.getState.mockReset() + }) + + it('should get the limits from the sceneMetrics state in the server', async () => { + await expect(client.getLimits()).resolves.toBe(limits) + }) + }) + + describe('When using the getEntitiesOutOfBoundaries method of the client', () => { + beforeEach(() => { + store.getState.mockReturnValueOnce({ + sceneMetrics: { + entitiesOutOfBoundaries: 1 + } + }) + }) + + afterEach(() => { + store.getState.mockReset() + }) + + it('should get the entities out of boundaries from the sceneMetrics state in the server', async () => { + await expect(client.getEntitiesOutOfBoundaries()).resolves.toBe(1) + }) + }) +}) diff --git a/packages/@dcl/inspector/src/lib/rpc/scene-metrics/server.ts b/packages/@dcl/inspector/src/lib/rpc/scene-metrics/server.ts new file mode 100644 index 000000000..5f1b4671e --- /dev/null +++ b/packages/@dcl/inspector/src/lib/rpc/scene-metrics/server.ts @@ -0,0 +1,21 @@ +import { RPC, Transport } from '@dcl/mini-rpc' +import { RootState } from '../../../redux/store' +import { SceneMetricsRPC } from './types' + +export class SceneMetricsServer extends RPC { + constructor(transport: Transport, store: { getState: () => RootState }) { + super(SceneMetricsRPC.name, transport) + + this.handle(SceneMetricsRPC.Method.GET_METRICS, async () => { + return store.getState().sceneMetrics.metrics + }) + + this.handle(SceneMetricsRPC.Method.GET_LIMITS, async () => { + return store.getState().sceneMetrics.limits + }) + + this.handle(SceneMetricsRPC.Method.GET_ENTITIES_OUT_OF_BOUNDARIES, async () => { + return store.getState().sceneMetrics.entitiesOutOfBoundaries + }) + } +} diff --git a/packages/@dcl/inspector/src/lib/rpc/scene-metrics/types.ts b/packages/@dcl/inspector/src/lib/rpc/scene-metrics/types.ts new file mode 100644 index 000000000..4fb056722 --- /dev/null +++ b/packages/@dcl/inspector/src/lib/rpc/scene-metrics/types.ts @@ -0,0 +1,23 @@ +import { SceneMetrics } from '../../../redux/scene-metrics/types' + +export namespace SceneMetricsRPC { + export const name = 'SceneMetricsRPC' + + export enum Method { + GET_METRICS = 'get_metrics', + GET_LIMITS = 'get_limits', + GET_ENTITIES_OUT_OF_BOUNDARIES = 'get_entities_out_of_boundaries' + } + + export type Params = { + [Method.GET_METRICS]: undefined + [Method.GET_LIMITS]: undefined + [Method.GET_ENTITIES_OUT_OF_BOUNDARIES]: undefined + } + + export type Result = { + [Method.GET_METRICS]: SceneMetrics + [Method.GET_LIMITS]: SceneMetrics + [Method.GET_ENTITIES_OUT_OF_BOUNDARIES]: number + } +} diff --git a/packages/@dcl/inspector/src/redux/scene-metrics/index.ts b/packages/@dcl/inspector/src/redux/scene-metrics/index.ts new file mode 100644 index 000000000..fe88512f8 --- /dev/null +++ b/packages/@dcl/inspector/src/redux/scene-metrics/index.ts @@ -0,0 +1,54 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit' +import { RootState } from '../store' +import { SceneMetrics } from './types' + +export interface SceneMetricsState { + metrics: SceneMetrics + limits: SceneMetrics + entitiesOutOfBoundaries: number +} + +export const initialState: SceneMetricsState = { + metrics: { + triangles: 0, + entities: 0, + bodies: 0, + materials: 0, + textures: 0 + }, + limits: { + triangles: 0, + entities: 0, + bodies: 0, + materials: 0, + textures: 0 + }, + entitiesOutOfBoundaries: 0 +} + +export const sceneMetrics = createSlice({ + name: 'scene-metrics', + initialState, + reducers: { + setMetrics: (state, { payload }: PayloadAction) => { + state.metrics = payload + }, + setLimits(state, { payload }: PayloadAction) { + state.limits = payload + }, + setEntitiesOutOfBoundaries: (state, { payload }: PayloadAction) => { + state.entitiesOutOfBoundaries = payload + } + } +}) + +// Actions +export const { setMetrics, setEntitiesOutOfBoundaries, setLimits } = sceneMetrics.actions + +// Selectors +export const getMetrics = (state: RootState): SceneMetrics => state.sceneMetrics.metrics +export const getLimits = (state: RootState): SceneMetrics => state.sceneMetrics.limits +export const getEntitiesOutOfBoundaries = (state: RootState): number => state.sceneMetrics.entitiesOutOfBoundaries + +// Reducer +export default sceneMetrics.reducer diff --git a/packages/@dcl/inspector/src/redux/scene-metrics/types.ts b/packages/@dcl/inspector/src/redux/scene-metrics/types.ts new file mode 100644 index 000000000..8ea728115 --- /dev/null +++ b/packages/@dcl/inspector/src/redux/scene-metrics/types.ts @@ -0,0 +1,7 @@ +export type SceneMetrics = { + triangles: number + entities: number + bodies: number + materials: number + textures: number +} diff --git a/packages/@dcl/inspector/src/redux/store.ts b/packages/@dcl/inspector/src/redux/store.ts index d57274cd3..1bac168db 100644 --- a/packages/@dcl/inspector/src/redux/store.ts +++ b/packages/@dcl/inspector/src/redux/store.ts @@ -6,7 +6,9 @@ import appStateReducer from './app' import dataLayerReducer from './data-layer' import sdkReducer from './sdk' import uiReducer from './ui' +import sceneMetricsReducer from './scene-metrics' import { UiServer } from '../lib/rpc/ui/server' +import { SceneMetricsServer } from '../lib/rpc/scene-metrics/server' import sagas from './root-saga' import { getConfig } from '../lib/logic/config' @@ -17,7 +19,8 @@ export const store = configureStore({ dataLayer: dataLayerReducer, sdk: sdkReducer, app: appStateReducer, - ui: uiReducer + ui: uiReducer, + sceneMetrics: sceneMetricsReducer }, middleware: (getDefaultMiddleware) => { return getDefaultMiddleware({ thunk: false, serializableCheck: false }).concat(sagaMiddleware) @@ -29,6 +32,7 @@ const config = getConfig() if (config.dataLayerRpcParentUrl) { const tranport = new MessageTransport(window, window.parent, config.dataLayerRpcParentUrl) new UiServer(tranport, store) + new SceneMetricsServer(tranport, store) } sagaMiddleware.run(sagas) diff --git a/packages/@dcl/inspector/src/tooling-entrypoint.ts b/packages/@dcl/inspector/src/tooling-entrypoint.ts index bb096a8d0..3e57cd8e3 100644 --- a/packages/@dcl/inspector/src/tooling-entrypoint.ts +++ b/packages/@dcl/inspector/src/tooling-entrypoint.ts @@ -2,3 +2,4 @@ export * from './lib/data-layer/remote-data-layer' export * from './lib/rpc/camera/client' export * from './lib/rpc/ui/client' export * from './lib/logic/storage' +export * from './lib/rpc/scene-metrics/client'