Skip to content

Commit dc0d1de

Browse files
georginahalpernGeorgina
andauthored
[Inspectorv2] Particle attractors (#17012)
This PR ports over the particle attractor functionality from inspector (previously in particleAttractorGridComponent, attractorGridComponents, and attracotrGridComponent) using a slightly different approach (the impostors are no longer stored in attractor_untypedImpostor, instead they are simply react components rendered as a ListItem for each attractor). Using the new useResource hooks and react state to keep the impostor color/scale/label/impostorPosition/afterRender observables in sync Other changes include - common component for ToggleButton, used by scene explorer toggle command as well - made list.tsx handel generic listitem more typesafe so that the type of item in teh renderItem function is the same type as the itemslist data passed in - new useAsyncResource hook , used to load the fontasset/textrenderer aync - There is still some work to do (represented as TODOs in the code) - such as separating particleSystemPropertiesService into IParticleSystem/ParticleSystem properties, as well as consolidating the icon strategy. --------- Co-authored-by: Georgina <[email protected]>
1 parent b31f466 commit dc0d1de

File tree

11 files changed

+478
-133
lines changed

11 files changed

+478
-133
lines changed
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { makeStyles, tokens } from "@fluentui/react-components";
2+
import { ArrowMoveFilled, EyeFilled, EyeOffFilled } from "@fluentui/react-icons";
3+
import { FontAsset } from "addons/msdfText/fontAsset";
4+
import { TextRenderer } from "addons/msdfText/textRenderer";
5+
import type { StandardMaterial } from "core/Materials";
6+
import type { Color3 } from "core/Maths";
7+
import { Color4, Matrix } from "core/Maths";
8+
import type { AbstractMesh } from "core/Meshes";
9+
import { CreateSphere } from "core/Meshes";
10+
import type { Observer } from "core/Misc";
11+
import type { Attractor } from "core/Particles";
12+
import type { Scene } from "core/scene";
13+
import { useCallback, useEffect, useRef, useState, type FunctionComponent } from "react";
14+
import { SyncedSliderInput } from "shared-ui-components/fluent/primitives/syncedSlider";
15+
import { ToggleButton } from "shared-ui-components/fluent/primitives/toggleButton";
16+
import { useAsyncResource, useResource } from "../../../hooks/resourceHooks";
17+
18+
type AttractorProps = {
19+
attractor: Attractor;
20+
id: number;
21+
impostorScale: number;
22+
impostorColor: Color3;
23+
impostorMaterial: StandardMaterial;
24+
scene: Scene;
25+
isControlled: (impostor: AbstractMesh) => boolean;
26+
onControl: (impostor?: AbstractMesh) => void;
27+
};
28+
29+
const useAttractorStyles = makeStyles({
30+
container: {
31+
// top-level div used for lineContainer, in UI overhaul update to just use linecontainer
32+
width: "100%",
33+
display: "flex", // Makes this a flex container
34+
flexDirection: "row", // Arranges children horizontally, main-axis=horizontal
35+
padding: `${tokens.spacingVerticalXS} 0px`,
36+
borderBottom: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`,
37+
},
38+
});
39+
40+
const CreateImpostor = (id: number, scene: Scene, attractor: Attractor, initialScale: number, initialMaterial: StandardMaterial) => {
41+
const impostor = CreateSphere("Attractor impostor #" + id, { diameter: 1 }, scene);
42+
impostor.scaling.setAll(initialScale);
43+
impostor.position.copyFrom(attractor.position);
44+
impostor.material = initialMaterial;
45+
impostor.reservedDataStore = { hidden: true };
46+
return impostor;
47+
};
48+
49+
async function CreateTextRendererAsync(scene: Scene, impostor: AbstractMesh, index: number, color: Color3) {
50+
const sdfFontDefinition = await (await fetch("https://assets.babylonjs.com/fonts/roboto-regular.json")).text();
51+
const fontAsset = new FontAsset(sdfFontDefinition, "https://assets.babylonjs.com/fonts/roboto-regular.png");
52+
53+
const textRenderer = await TextRenderer.CreateTextRendererAsync(fontAsset, scene.getEngine());
54+
textRenderer.addParagraph("#" + index, {}, Matrix.Scaling(0.5, 0.5, 0.5).multiply(Matrix.Translation(0, 1, 0)));
55+
textRenderer.isBillboard = true;
56+
textRenderer.color = Color4.FromColor3(color, 1.0);
57+
textRenderer.render(scene.getViewMatrix(), scene.getProjectionMatrix());
58+
textRenderer.parent = impostor;
59+
return textRenderer;
60+
}
61+
62+
/**
63+
* Represents the UX of an attractor, a sphere with a color/size whose position matches that of the underlying attractor
64+
* @param props
65+
* @returns
66+
*/
67+
export const AttractorComponent: FunctionComponent<AttractorProps> = (props) => {
68+
const { attractor, id, impostorScale, impostorMaterial, scene } = props;
69+
const classes = useAttractorStyles();
70+
const [shown, setShown] = useState(true);
71+
72+
// Create observer and cleanup on unmount (we can't use useResource since Observer is not an IDisposable)
73+
const sceneOnAfterRenderObserverRef = useRef<Observer<Scene>>();
74+
useEffect(() => () => sceneOnAfterRenderObserverRef.current?.remove(), []);
75+
76+
// We only want to recreate the impostor mesh and associated if id, scene, or attractor/impostor changes
77+
const impostor = useResource(useCallback(() => CreateImpostor(id, scene, attractor, impostorScale, impostorMaterial), [id, scene, attractor]));
78+
const label = useAsyncResource(useCallback(async () => await CreateTextRendererAsync(scene, impostor, id, props.impostorColor), [scene, impostor, id]));
79+
80+
// If impostor, color, or label change, recreate the observer function so that it isnt hooked to old state
81+
useEffect(() => {
82+
sceneOnAfterRenderObserverRef.current?.remove();
83+
sceneOnAfterRenderObserverRef.current = scene.onAfterRenderObservable.add(() => {
84+
attractor.position.copyFrom(impostor.position);
85+
if (label) {
86+
label.color = Color4.FromColor3(props.impostorColor);
87+
label.render(scene.getViewMatrix(), scene.getProjectionMatrix());
88+
}
89+
});
90+
}, [impostor, label, props.impostorColor]);
91+
92+
useEffect(() => {
93+
impostor.scaling.setAll(impostorScale);
94+
}, [impostorScale]);
95+
96+
return (
97+
<div className={classes.container}>
98+
<SyncedSliderInput value={attractor.strength} onChange={(value) => (attractor.strength = value)} min={-10} max={10} step={0.1} />
99+
<ToggleButton
100+
title="Show / hide particle attractor."
101+
enabledIcon={EyeFilled}
102+
disabledIcon={EyeOffFilled}
103+
value={shown}
104+
onChange={(show: boolean) => {
105+
show ? (impostor.visibility = 1) : (impostor.visibility = 0);
106+
setShown(show);
107+
}}
108+
/>
109+
<ToggleButton
110+
title="Add / remove position gizmo from particle attractor"
111+
enabledIcon={ArrowMoveFilled}
112+
value={props.isControlled(impostor)}
113+
onChange={(control: boolean) => props.onControl(control ? impostor : undefined)}
114+
/>
115+
</div>
116+
);
117+
};
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { GizmoManager } from "core/Gizmos";
2+
import { StandardMaterial } from "core/Materials";
3+
import { Color3 } from "core/Maths";
4+
import type { AbstractMesh } from "core/Meshes";
5+
import { Attractor } from "core/Particles";
6+
import type { ParticleSystem } from "core/Particles";
7+
import type { Scene } from "core/scene";
8+
import type { Nullable } from "core/types";
9+
import { useCallback, useEffect, useState, type FunctionComponent } from "react";
10+
import { Color3PropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/colorPropertyLine";
11+
import { SyncedSliderPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/syncedSliderPropertyLine";
12+
import { List } from "shared-ui-components/fluent/primitives/list";
13+
import type { ListItem } from "shared-ui-components/fluent/primitives/list";
14+
import { useResource } from "../../../hooks/resourceHooks";
15+
import { AttractorComponent } from "./attractor";
16+
type AttractorListProps = {
17+
scene: Scene;
18+
gizmoManager: GizmoManager;
19+
attractors: Array<Attractor>;
20+
system: ParticleSystem;
21+
};
22+
23+
// For each Attractor, create a listItem consisting of the attractor and its debugging impostor mesh
24+
function AttractorsToListItems(attractors: Nullable<Array<Attractor>>) {
25+
return (
26+
attractors?.map((attractor, index) => {
27+
return {
28+
id: index,
29+
data: attractor,
30+
sortBy: 0,
31+
};
32+
}) ?? []
33+
);
34+
}
35+
36+
const CreateGizmoManager = (scene: Scene) => {
37+
const gizmoManager = new GizmoManager(scene);
38+
gizmoManager.positionGizmoEnabled = true;
39+
gizmoManager.attachableMeshes = [];
40+
return gizmoManager;
41+
};
42+
const CreateSharedMaterial = (scene: Scene, impostorColor: Color3) => {
43+
const material = new StandardMaterial("Attractor impostor material", scene);
44+
material.diffuseColor = impostorColor;
45+
material.reservedDataStore = { hidden: true }; // Ensure scene explorer doesn't show the material
46+
return material;
47+
};
48+
49+
export const AttractorList: FunctionComponent<AttractorListProps> = (props) => {
50+
const { scene, system } = props;
51+
const [items, setItems] = useState<Array<ListItem<Attractor>>>([]);
52+
53+
// All impostors share a scale and material/color (for now!)
54+
const [impostorScale, setImpostorScale] = useState(1);
55+
const [impostorColor, setImpostorColor] = useState<Color3>(() => Color3.White());
56+
const impostorMaterial = useResource(useCallback(() => CreateSharedMaterial(scene, impostorColor), [scene]));
57+
58+
// All impostors share a gizmoManager. controlledImpostor state ensures re-render of children so that their gizmoEnabled toggle is accurate
59+
const gizmoManager = useResource(useCallback(() => CreateGizmoManager(scene), [scene]));
60+
const [controlledImpostor, setControlledImpostor] = useState<Nullable<AbstractMesh>>(null);
61+
62+
// If attractors change, recreate the items to re-render attractor components
63+
useEffect(() => {
64+
setItems(AttractorsToListItems(props.attractors));
65+
}, [props.attractors]);
66+
67+
// If color changes, update shared material to ensure children reflect new color
68+
useEffect(() => {
69+
impostorMaterial.diffuseColor = impostorColor;
70+
}, [impostorColor]);
71+
72+
const onControlImpostor = (impostor?: AbstractMesh) => {
73+
// If an impostor is passed, attach the gizmo to the current impostor, otherwise it will detach (i.e. set to null)
74+
const attached = impostor ?? null;
75+
gizmoManager.attachToMesh(attached);
76+
setControlledImpostor(attached);
77+
};
78+
79+
return (
80+
<>
81+
{items.length > 0 && (
82+
<>
83+
<Color3PropertyLine label="Attractor debug color" value={impostorColor} onChange={setImpostorColor} />
84+
<SyncedSliderPropertyLine label="Attractor debug size" value={impostorScale} onChange={setImpostorScale} min={0} max={10} step={0.1} />
85+
</>
86+
)}
87+
<List
88+
addButtonLabel={`Add new attractor`}
89+
items={items}
90+
onDelete={(item, _index) => system.removeAttractor(item.data)}
91+
onAdd={(item) => system.addAttractor(item?.data ?? new Attractor())}
92+
renderItem={(item) => {
93+
return (
94+
<AttractorComponent
95+
attractor={item.data}
96+
id={item.id}
97+
scene={scene}
98+
impostorColor={impostorColor}
99+
impostorScale={impostorScale}
100+
impostorMaterial={impostorMaterial}
101+
isControlled={(impostor: AbstractMesh) => impostor === controlledImpostor}
102+
onControl={onControlImpostor}
103+
/>
104+
);
105+
}}
106+
/>
107+
</>
108+
);
109+
};

packages/dev/inspector-v2/src/components/properties/particles/particleSystemProperties.tsx

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { FactorGradient, ColorGradient as Color4Gradient, IParticleSystem, IValueGradient } from "core/index";
1+
import type { FactorGradient, ColorGradient as Color4Gradient, IValueGradient, ParticleSystem } from "core/index";
2+
import { GizmoManager } from "core/Gizmos";
23

34
import { Color3, Color4 } from "core/Maths/math.color";
45
import { useCallback } from "react";
@@ -7,20 +8,12 @@ import type { FunctionComponent } from "react";
78
import { useInterceptObservable } from "../../../hooks/instrumentationHooks";
89
import { useObservableState } from "../../../hooks/observableHooks";
910
import { Color4GradientList, FactorGradientList } from "shared-ui-components/fluent/hoc/gradientList";
11+
import { AttractorList } from "./attractorList";
1012

11-
export const ParticleSystemEmissionProperties: FunctionComponent<{ particleSystem: IParticleSystem }> = (props) => {
13+
export const ParticleSystemEmissionProperties: FunctionComponent<{ particleSystem: ParticleSystem }> = (props) => {
1214
const { particleSystem: system } = props;
1315

14-
// TODO-iv2: Perhaps a common enough pattern to create a custom hook
15-
const emitRateGradients = useObservableState(
16-
useCallback(() => {
17-
const gradients = system.getEmitRateGradients();
18-
return [...(gradients ?? [])];
19-
}, [system]),
20-
useInterceptObservable("function", system, "addEmitRateGradient"),
21-
useInterceptObservable("function", system, "removeEmitRateGradient"),
22-
useInterceptObservable("function", system, "forceRefreshGradients")
23-
);
16+
const emitRateGradients = useParticleSystemProperty(system, "getEmitRateGradients", "function", "addEmitRateGradient", "removeEmitRateGradient", "forceRefreshGradients");
2417

2518
return (
2619
<>
@@ -45,27 +38,18 @@ export const ParticleSystemEmissionProperties: FunctionComponent<{ particleSyste
4538
);
4639
};
4740

48-
export const ParticleSystemColorProperties: FunctionComponent<{ particleSystem: IParticleSystem }> = (props) => {
41+
export const ParticleSystemColorProperties: FunctionComponent<{ particleSystem: ParticleSystem }> = (props) => {
4942
const { particleSystem: system } = props;
5043

51-
const colorGradients = useObservableState(
52-
useCallback(() => {
53-
const gradients = system.getColorGradients();
54-
return [...(gradients ?? [])];
55-
}, [system]),
56-
useInterceptObservable("function", system, "addColorGradient"),
57-
useInterceptObservable("function", system, "removeColorGradient"),
58-
useInterceptObservable("function", system, "forceRefreshGradients")
59-
);
60-
44+
const colorGradients = useParticleSystemProperty(system, "getColorGradients", "function", "addColorGradient", "removeColorGradient", "forceRefreshGradients");
6145
return (
6246
<>
6347
{!system.isNodeGenerated && (
6448
<Color4GradientList
6549
gradients={colorGradients}
6650
label="Color Gradient"
6751
removeGradient={(gradient: IValueGradient) => {
68-
system.removeEmitRateGradient(gradient.gradient);
52+
system.removeColorGradient(gradient.gradient);
6953
system.forceRefreshGradients();
7054
}}
7155
addGradient={(gradient?: Color4Gradient) => {
@@ -85,3 +69,36 @@ export const ParticleSystemColorProperties: FunctionComponent<{ particleSystem:
8569
</>
8670
);
8771
};
72+
73+
export const ParticleSystemAttractorProperties: FunctionComponent<{ particleSystem: ParticleSystem }> = (props) => {
74+
const { particleSystem: system } = props;
75+
const gizmoManager = new GizmoManager(system.getScene()!);
76+
77+
const attractors = useParticleSystemProperty(system, "attractors", "property", "addAttractor", "removeAttractor");
78+
79+
return (
80+
<>
81+
<AttractorList gizmoManager={gizmoManager} attractors={attractors} scene={system.getScene()!} system={system} />
82+
</>
83+
);
84+
};
85+
86+
// TODO-iv2: This can be more generic to work for not just particleSystems
87+
const useParticleSystemProperty = (
88+
system: ParticleSystem,
89+
propertyKey: keyof ParticleSystem,
90+
observableType: "function" | "property",
91+
addFn: keyof ParticleSystem,
92+
removeFn: keyof ParticleSystem,
93+
changeFn?: keyof ParticleSystem
94+
) => {
95+
return useObservableState(
96+
useCallback(() => {
97+
const value = observableType === "function" ? system[propertyKey]() : system[propertyKey];
98+
return [...(value ?? [])];
99+
}, [system, propertyKey]),
100+
useInterceptObservable("function", system, addFn),
101+
useInterceptObservable("function", system, removeFn),
102+
changeFn ? useInterceptObservable("function", system, changeFn) : undefined
103+
);
104+
};

packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import type { IDisposable, IReadonlyObservable, Nullable, Scene } from "core/ind
33
import type { TreeItemValue, TreeOpenChangeData, TreeOpenChangeEvent } from "@fluentui/react-components";
44
import type { ScrollToInterface } from "@fluentui/react-components/unstable";
55
import type { ComponentType, FunctionComponent } from "react";
6-
7-
import { Body1, Body1Strong, Button, FlatTree, FlatTreeItem, makeStyles, SearchBox, ToggleButton, tokens, Tooltip, TreeItemLayout } from "@fluentui/react-components";
6+
import { ToggleButton } from "shared-ui-components/fluent/primitives/toggleButton";
7+
import { Body1, Body1Strong, Button, FlatTree, FlatTreeItem, makeStyles, SearchBox, tokens, Tooltip, TreeItemLayout } from "@fluentui/react-components";
88
import { VirtualizerScrollView } from "@fluentui/react-components/unstable";
99
import { FilterRegular, MoviesAndTvRegular } from "@fluentui/react-icons";
10+
import type { FluentIcon } from "@fluentui/react-icons";
1011

1112
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1213
import { useObservableState } from "../../hooks/observableHooks";
@@ -214,11 +215,8 @@ const ToggleCommand: FunctionComponent<{ command: ToggleCommand<EntityBase>; ent
214215
command.onChange
215216
);
216217

217-
return (
218-
<Tooltip content={displayName} relationship="label" positioning={"after"}>
219-
<ToggleButton icon={<Icon />} appearance="transparent" checked={isEnabled} onClick={() => (command.isEnabled = !command.isEnabled)} />
220-
</Tooltip>
221-
);
218+
// TODO-iv2: Consolidate icon prop passing approach for inspector and shared components
219+
return <ToggleButton title={displayName} enabledIcon={Icon as FluentIcon} value={isEnabled} onChange={(val: boolean) => (command.isEnabled = val)} />;
222220
};
223221

224222
const SceneTreeItem: FunctionComponent<{

0 commit comments

Comments
 (0)