Skip to content

Commit 338237b

Browse files
authored
Inspector v2: Add lightweight runtime "instrumentation" helpers and a usage example (#16748)
One of the problems we currently have in tooling (and especially in inspector v1) is having the UI react to scene state changes. Ideally we'd have observables for every mutable state of a scene, but the runtime overhead would be too high to make this practical. We currently work around this in one of a few ways: 1. In some of the tools, we use a global observable, where any part of the tool UI that mutates any scene state is supposed to fire the observable. This way all the different parts of the tool UI can communicate state changes that they initiate. 2. When state changes don't come from the tools (e.g. Playground, or really any app that uses Inspector), we just poll for state changes (e.g. continuously re-render react components every ~500ms for example to detect changes). 3. Provide a refresh button, where the user must click the button to see changes. This PR introduces a new idea, which I refer to as "lightweight runtime instrumentation." The idea is to temporarily hook function calls and property setters to produce a transient observable. This would not scale if we tried to hook every property on every object of the scene simultaneously, but in reality only a very small part of the scene state is bound to the inspector UI at any time, e.g.: 1. The properties of a single object (in the properties pane). 2. The names of as many nodes as fit into scene explorer (since it virtualizes, so there are not that many live react components for tree view nodes bound to scene entities). When a different entity is bound to the properties pane, or the scene explorer is scrolled, the temporary hooks are removed and the entity objects are restored to their original state. With this in mind, hooking functions/properties to observe scene state changes specifically for tooling should be quite low overhead, so this PR introduces the idea and has one example of the usage. There are helper functions for "intercepting" function calls and property setters, and then a React hook that translates this to React state, e.g.: `const computeBonesUsingShadersObservable = useInterceptObservable("property", mesh, "computeBonesUsingShaders")` This observable (and the temporary instrumentation of the `computeBonesUsingShaders` property) is reverted as soon as the consuming react component is unmounted. Take a look in this order: 1. The `useObservableState` in [meshGeneralProperties.tsx](https://github.com/BabylonJS/Babylon.js/pull/16748/files#diff-dca74d1795e7e366439f24e66a2a26fe0b14ef5f3fc4ceb3b05585033b781955R10) - this is just showing an existing concept where given an observable, we can produce React state that the component can use to re-render. 2. The `useInterceptObservable` used along with `useObservableState` in [meshAdvancedProperties.tsx](https://github.com/BabylonJS/Babylon.js/pull/16748/files#diff-1fc176dd99343ae0ff259bf520c6504813ab7c02fbbb06292280310d7a2e588dR11) - this shows using the new `useInterceptObservable` to create a temporary observable that lets us immediately respond to state changes of a property that doesn't actually have an observable. 4. The `useObservableState` implementation in [instrumentationHooks.ts](https://github.com/BabylonJS/Babylon.js/pull/16748/files#diff-92b79d9fbaaf8f9eef52315919e1ae5640553f1262ca2c43618f091ebb29de4dR19). 5. The `InterceptFunction` helper function in [functionInstrumentation.ts](https://github.com/BabylonJS/Babylon.js/pull/16748/files#diff-0124bc224339ce336c700bf1128d94268f9abcc1ad92cebeb171e825e250d07cR20). 6. The `InterceptProperty` helper function in [propertyInstrumentation.ts](https://github.com/BabylonJS/Babylon.js/pull/16748/files#diff-ec2332f5239be4ddf8eab5cb2faa5bb1cf36da855b214cbc44a25f43981a6496R20). Open to feedback on this idea. So far it seems like it works quite well.
1 parent 8b22575 commit 338237b

File tree

6 files changed

+311
-0
lines changed

6 files changed

+311
-0
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// eslint-disable-next-line import/no-internal-modules
2+
import type { IDisposable, IReadonlyObservable } from "core/index";
3+
4+
import { useEffect, useMemo } from "react";
5+
6+
import { Observable } from "core/Misc/observable";
7+
8+
import { InterceptFunction } from "../instrumentation/functionInstrumentation";
9+
import { InterceptProperty } from "../instrumentation/propertyInstrumentation";
10+
11+
/**
12+
* Provides an observable that fires when a specified function/property is called/set.
13+
* @param type The type of the interceptor, either "function" or "property".
14+
* @param target The object containing the function/property to intercept.
15+
* @param propertyKey The key of the function/property to intercept.
16+
* @returns An observable that fires when the function/property is called/set.
17+
*/
18+
// eslint-disable-next-line @typescript-eslint/naming-convention
19+
export function useInterceptObservable<T extends object>(type: "function" | "property", target: T, propertyKey: keyof T): IReadonlyObservable<void> {
20+
// Create a cached observable. It effectively has the lifetime of the component that uses this hook.
21+
const observable = useMemo(() => new Observable<void>(), []);
22+
23+
// Whenver the type, target, or property key changes, we need to set up a new interceptor.
24+
useEffect(() => {
25+
let interceptToken: IDisposable;
26+
27+
if (type === "function") {
28+
interceptToken = InterceptFunction(target, propertyKey, {
29+
afterCall: () => {
30+
observable.notifyObservers();
31+
},
32+
});
33+
} else if (type === "property") {
34+
interceptToken = InterceptProperty(target, propertyKey, {
35+
afterSet: () => {
36+
observable.notifyObservers();
37+
},
38+
});
39+
} else {
40+
throw new Error(`Unknown interceptor type: ${type}`);
41+
}
42+
43+
// When the effect is cleaned up, we need to dispose of the interceptor.
44+
return () => {
45+
interceptToken.dispose();
46+
};
47+
}, [type, target, propertyKey, observable]);
48+
49+
return observable;
50+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// eslint-disable-next-line import/no-internal-modules
2+
import type { IDisposable } from "core/index";
3+
4+
export type FunctionHooks = {
5+
/**
6+
* This function will be called after the hooked function is called.
7+
*/
8+
afterCall?: () => void;
9+
};
10+
11+
const InterceptorHooksMaps = new WeakMap<object, Map<PropertyKey, FunctionHooks[]>>();
12+
13+
/**
14+
* Intercepts a function on an object and allows you to add hooks that will be called during function execution.
15+
* @param target The object containing the function to intercept.
16+
* @param propertyKey The key of the property that is a function (this is the function that will be intercepted).
17+
* @param hooks The hooks to call during the function execution.
18+
* @returns A disposable that removes the hooks when disposed and returns the object to its original state.
19+
*/
20+
export function InterceptFunction<T extends object>(target: T, propertyKey: keyof T, hooks: FunctionHooks): IDisposable {
21+
if (!hooks.afterCall) {
22+
throw new Error("At least one hook must be provided.");
23+
}
24+
25+
const originalFunction = Reflect.get(target, propertyKey, target) as (...args: any) => any;
26+
if (typeof originalFunction !== "function") {
27+
throw new Error(`Property "${propertyKey.toString()}" of object "${target}" is not a function.`);
28+
}
29+
30+
// Make sure the property is configurable and writable, otherwise it is immutable and cannot be intercepted.
31+
const propertyDescriptor = Reflect.getOwnPropertyDescriptor(target, propertyKey);
32+
if (propertyDescriptor) {
33+
if (!propertyDescriptor.configurable) {
34+
throw new Error(`Property "${propertyKey.toString()}" of object "${target}" is not configurable.`);
35+
}
36+
37+
if (propertyDescriptor.writable === false || (propertyDescriptor.writable === undefined && !propertyDescriptor.set)) {
38+
throw new Error(`Property "${propertyKey.toString()}" of object "${target}" is readonly.`);
39+
}
40+
}
41+
42+
// Get or create the hooks map for the target object.
43+
let hooksMap = InterceptorHooksMaps.get(target);
44+
if (!hooksMap) {
45+
InterceptorHooksMaps.set(target, (hooksMap = new Map()));
46+
}
47+
48+
// Get or create the hooks array for the property key.
49+
let hooksForKey = hooksMap.get(propertyKey);
50+
if (!hooksForKey) {
51+
hooksMap.set(propertyKey, (hooksForKey = []));
52+
if (
53+
// Replace the function with a new one that calls the hooks in addition to the original function.
54+
!Reflect.set(target, propertyKey, (...args: any) => {
55+
const result = Reflect.apply(originalFunction, target, args);
56+
for (const { afterCall } of hooksForKey!) {
57+
afterCall?.();
58+
}
59+
return result;
60+
})
61+
) {
62+
throw new Error(`Failed to define new function "${propertyKey.toString()}" on object "${target}".`);
63+
}
64+
}
65+
hooksForKey.push(hooks);
66+
67+
let isDisposed = false;
68+
return {
69+
dispose: () => {
70+
if (!isDisposed) {
71+
// Remove the hooks from the hooks array for the property key.
72+
hooksForKey.splice(hooksForKey.indexOf(hooks), 1);
73+
74+
// If there are no more hooks for the property key, remove the property from the hooks map.
75+
if (hooksForKey.length === 0) {
76+
hooksMap.delete(propertyKey);
77+
78+
// If there are no more hooks for the target object, remove the hooks map from the WeakMap.
79+
if (hooksMap.size === 0) {
80+
InterceptorHooksMaps.delete(target);
81+
}
82+
83+
if (propertyDescriptor) {
84+
// If we have a property descriptor, it means the property was defined directly on the target object,
85+
// in which case we replaced it and the original property descriptor needs to be restored.
86+
if (!Reflect.defineProperty(target, propertyKey, propertyDescriptor)) {
87+
throw new Error(`Failed to restore original function "${propertyKey.toString()}" on object "${target}".`);
88+
}
89+
} else {
90+
// Otherwise, the property was inherited through the prototype chain, and so we can simply delete it from
91+
// the target object to allow it to fall back to the prototype chain as it did originally.
92+
if (!Reflect.deleteProperty(target, propertyKey)) {
93+
throw new Error(`Failed to delete transient function "${propertyKey.toString()}" on object "${target}".`);
94+
}
95+
}
96+
}
97+
98+
isDisposed = true;
99+
}
100+
},
101+
};
102+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// eslint-disable-next-line import/no-internal-modules
2+
import type { IDisposable } from "core/index";
3+
4+
export type PropertyHooks = {
5+
/**
6+
* This function will be called after the hooked property is set.
7+
*/
8+
afterSet?: () => void;
9+
};
10+
11+
const InterceptorHooksMaps = new WeakMap<object, Map<PropertyKey, PropertyHooks[]>>();
12+
13+
/**
14+
* Intercepts a property on an object and allows you to add hooks that will be called when the property is get or set.
15+
* @param target The object containing the property to intercept.
16+
* @param propertyKey The key of the property to intercept.
17+
* @param hooks The hooks to call when the property is get or set.
18+
* @returns A disposable that removes the hooks when disposed and returns the object to its original state.
19+
*/
20+
export function InterceptProperty<T extends object>(target: T, propertyKey: keyof T, hooks: PropertyHooks): IDisposable {
21+
// Find the property descriptor and note the owning object (might be inherited through the prototype chain).
22+
let propertyOwner: object | null = target;
23+
let propertyDescriptor: PropertyDescriptor | undefined;
24+
while (propertyOwner) {
25+
if ((propertyDescriptor = Reflect.getOwnPropertyDescriptor(propertyOwner, propertyKey))) {
26+
break;
27+
}
28+
propertyOwner = Reflect.getPrototypeOf(propertyOwner);
29+
}
30+
31+
if (!propertyDescriptor) {
32+
throw new Error(`Property "${propertyKey.toString()}" not found on "${target}" or in its prototype chain.`);
33+
}
34+
35+
// Make sure the property is configurable and writable, otherwise it is immutable and cannot be intercepted.
36+
if (!propertyDescriptor.configurable) {
37+
throw new Error(`Property "${propertyKey.toString()}" of object "${target}" is not configurable.`);
38+
}
39+
if (propertyDescriptor.writable === false || (propertyDescriptor.writable === undefined && !propertyDescriptor.set)) {
40+
throw new Error(`Property "${propertyKey.toString()}" of object "${target}" is readonly.`);
41+
}
42+
43+
// Get or create the hooks map for the target object.
44+
let hooksMap = InterceptorHooksMaps.get(target);
45+
if (!hooksMap) {
46+
InterceptorHooksMaps.set(target, (hooksMap = new Map()));
47+
}
48+
49+
// Get or create the hooks array for the property key.
50+
let hooksForKey = hooksMap.get(propertyKey);
51+
if (!hooksForKey) {
52+
hooksMap.set(propertyKey, (hooksForKey = []));
53+
54+
let { get: getValue, set: setValue } = propertyDescriptor;
55+
56+
// We already checked that the property is writable, so if there is no setter, then it must be a value property.
57+
// In this case, getValue can return the direct value, and setValue can set the direct value.
58+
if (!setValue) {
59+
getValue = () => propertyDescriptor.value;
60+
setValue = (value: any) => (propertyDescriptor.value = value);
61+
}
62+
63+
if (
64+
// Replace the property with a new one that calls the hooks in addition to the original getter and setter.
65+
!Reflect.defineProperty(target, propertyKey, {
66+
configurable: true,
67+
get: getValue ? () => getValue.call(target) : undefined,
68+
set: (newValue: any) => {
69+
setValue.call(target, newValue);
70+
for (const { afterSet } of hooksForKey!) {
71+
afterSet?.();
72+
}
73+
},
74+
})
75+
) {
76+
throw new Error(`Failed to define new property "${propertyKey.toString()}" on object "${target}".`);
77+
}
78+
}
79+
hooksForKey.push(hooks);
80+
81+
// Take note of whether the property is owned by the target object or inherited from its prototype chain.
82+
const isOwnProperty = propertyOwner === target;
83+
84+
let isDisposed = false;
85+
return {
86+
dispose: () => {
87+
if (!isDisposed) {
88+
// Remove the hooks from the hooks array for the property key.
89+
hooksForKey.splice(hooksForKey.indexOf(hooks), 1);
90+
91+
// If there are no more hooks for the property key, remove the property from the hooks map.
92+
if (hooksForKey.length === 0) {
93+
hooksMap.delete(propertyKey);
94+
95+
// If there are no more hooks for the target object, remove the hooks map from the WeakMap.
96+
if (hooksMap.size === 0) {
97+
InterceptorHooksMaps.delete(target);
98+
}
99+
100+
if (isOwnProperty) {
101+
// If the property is owned by the target object, it means the property was defined directly on the target object,
102+
// in which case we replaced it and the original property descriptor needs to be restored.
103+
if (!Reflect.defineProperty(target, propertyKey, propertyDescriptor)) {
104+
throw new Error(`Failed to restore original property descriptor "${propertyKey.toString()}" on object "${target}".`);
105+
}
106+
} else {
107+
// Otherwise, the property was inherited through the prototype chain, and so we can simply delete it from
108+
// the target object to allow it to fall back to the prototype chain as it did originally.
109+
if (!Reflect.deleteProperty(target, propertyKey)) {
110+
throw new Error(`Failed to delete transient property descriptor "${propertyKey.toString()}" on object "${target}".`);
111+
}
112+
}
113+
}
114+
115+
isDisposed = true;
116+
}
117+
},
118+
};
119+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// eslint-disable-next-line import/no-internal-modules
2+
import type { AbstractMesh } from "core/index";
3+
4+
import type { FunctionComponent } from "react";
5+
6+
import { useInterceptObservable } from "../../../../hooks/instrumentationHooks";
7+
import { useObservableState } from "../../../../hooks/observableHooks";
8+
9+
export const MeshAdvancedProperties: FunctionComponent<{ entity: AbstractMesh }> = ({ entity: mesh }) => {
10+
// There is no observable for computeBonesUsingShaders, so we use an interceptor to listen for changes.
11+
const computeBonesUsingShaders = useObservableState(() => mesh.computeBonesUsingShaders, useInterceptObservable("property", mesh, "computeBonesUsingShaders"));
12+
13+
return (
14+
// TODO: Use the new Fluent property line shared components.
15+
<>
16+
<div key="ComputeBonesUsingShaders">Compute bones using shaders: {computeBonesUsingShaders.toString()}</div>
17+
</>
18+
);
19+
};

packages/dev/inspector-v2/src/services/panes/properties/mesh/meshGeneralProperties.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@ import type { AbstractMesh } from "core/index";
33

44
import type { FunctionComponent } from "react";
55

6+
import { useObservableState } from "../../../../hooks/observableHooks";
7+
68
export const MeshGeneralProperties: FunctionComponent<{ entity: AbstractMesh }> = ({ entity: mesh }) => {
9+
// Use the observable to keep keep state up-to-date and re-render the component when it changes.
10+
const material = useObservableState(() => mesh.material, mesh.onMaterialChangedObservable);
11+
712
return (
813
// TODO: Use the new Fluent property line shared components.
914
<>
1015
<div key="MeshIsEnabled">Is enabled: {mesh.isEnabled(false).toString()}</div>
16+
{material && (!material.reservedDataStore || !material.reservedDataStore.hidden) && <div key="Material">Material: {material.name}</div>}
1117
</>
1218
);
1319
};

packages/dev/inspector-v2/src/services/panes/properties/mesh/meshPropertiesService.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import { AbstractMesh } from "core/Meshes/abstractMesh";
55

66
import { GeneralPropertiesSectionIdentity } from "../common/commonPropertiesService";
77
import { PropertiesServiceIdentity } from "../propertiesService";
8+
import { MeshAdvancedProperties } from "./meshAdvancedProperties";
89
import { MeshGeneralProperties } from "./meshGeneralProperties";
910
import { MeshTransformProperties } from "./meshTransformProperties";
1011

1112
export const TransformsPropertiesSectionIdentity = Symbol("Transforms");
13+
export const AdvancedPropertiesSectionIdentity = Symbol("Advanced");
1214

1315
export const MeshPropertiesServiceDefinition: ServiceDefinition<[], [IPropertiesService]> = {
1416
friendlyName: "Mesh Properties",
@@ -19,6 +21,11 @@ export const MeshPropertiesServiceDefinition: ServiceDefinition<[], [IProperties
1921
identity: TransformsPropertiesSectionIdentity,
2022
});
2123

24+
const advancedSectionRegistration = propertiesService.addSection({
25+
order: 2,
26+
identity: AdvancedPropertiesSectionIdentity,
27+
});
28+
2229
const contentRegistration = propertiesService.addSectionContent({
2330
key: "Mesh Properties",
2431
predicate: (entity: unknown) => entity instanceof AbstractMesh,
@@ -36,13 +43,21 @@ export const MeshPropertiesServiceDefinition: ServiceDefinition<[], [IProperties
3643
order: 0,
3744
component: MeshTransformProperties,
3845
},
46+
47+
// "ADVANCED" section.
48+
{
49+
section: AdvancedPropertiesSectionIdentity,
50+
order: 0,
51+
component: MeshAdvancedProperties,
52+
},
3953
],
4054
});
4155

4256
return {
4357
dispose: () => {
4458
contentRegistration.dispose();
4559
transformsSectionRegistration.dispose();
60+
advancedSectionRegistration.dispose();
4661
},
4762
};
4863
},

0 commit comments

Comments
 (0)