setPointerOver(true)} onMouseLeave={() => setPointerOver(false)}>
;
+ _options?: { samplers?: string[] };
+};
+
+function asShaderInternals(material: ShaderMaterial): ShaderMaterialPrivateViews {
+ return material as unknown as ShaderMaterialPrivateViews;
+}
+
+function getShaderTextureSlotNames(material: ShaderMaterial): string[] {
+ const m = asShaderInternals(material);
+ const fromOptions = m._options?.samplers?.filter((s): s is string => typeof s === "string" && s.length > 0) ?? [];
+ const fromTex = Object.keys(m._textures ?? {});
+ const seen = new Set
();
+ const ordered: string[] = [];
+ for (const k of fromOptions) {
+ if (!seen.has(k)) {
+ seen.add(k);
+ ordered.push(k);
+ }
+ }
+ for (const k of fromTex) {
+ if (!seen.has(k)) {
+ seen.add(k);
+ ordered.push(k);
+ }
+ }
+ return ordered;
+}
+
+export interface IEditorShaderMaterialInspectorProps {
+ mesh?: AbstractMesh;
+ material: ShaderMaterial;
+ scene: Scene;
+ onChange?: () => void;
+}
+
+export function EditorShaderMaterialInspector(props: IEditorShaderMaterialInspectorProps): ReactNode {
+ const { material, mesh, scene, onChange } = props;
+ const slots = getShaderTextureSlotNames(material);
+
+ return (
+ <>
+
+ onChange?.()} />
+ onChange?.()} />
+ onChange?.()} />
+ onChange?.()} />
+
+
+
+ {slots.length > 0 && (
+
+ {slots.map((slot) => (
+ onChange?.()} />
+ ))}
+
+ )}
+ >
+ );
+}
+
+function ShaderTextureSlotField(props: { material: ShaderMaterial; slot: string; scene: Scene; onChange: () => void }): ReactNode {
+ const { material, slot, scene, onChange } = props;
+ const proxy = {
+ get tex() {
+ return asShaderInternals(material)._textures?.[slot] ?? null;
+ },
+ set tex(value: BaseTexture | null) {
+ if (value) {
+ material.setTexture(slot, value);
+ } else {
+ material.removeTexture(slot);
+ }
+ onChange();
+ },
+ };
+
+ return ;
+}
diff --git a/editor/src/editor/layout/preview.tsx b/editor/src/editor/layout/preview.tsx
index 3c62329d1..336673054 100644
--- a/editor/src/editor/layout/preview.tsx
+++ b/editor/src/editor/layout/preview.tsx
@@ -61,17 +61,12 @@ import { getCameraFocusPositionFor } from "../../tools/camera/focus";
import { ITweenConfiguration, Tween } from "../../tools/animation/tween";
import { checkProjectCachedCompressedTextures } from "../../tools/assets/ktx";
import { createSceneLink, getRootSceneLink } from "../../tools/scene/scene-link";
-import {
- defaultGizmoSnapPreferences,
- gizmoSnapMinStep,
- IGizmoSnapPreferences,
- roundGizmoSnapSteps,
-} from "../../tools/gizmo-snap-preferences";
+import { defaultGizmoSnapPreferences, gizmoSnapMinStep, IGizmoSnapPreferences, roundGizmoSnapSteps } from "../../tools/gizmo-snap-preferences";
import { UniqueNumber, waitNextAnimationFrame, waitUntil } from "../../tools/tools";
import { isSprite, isSpriteManagerNode, isSpriteMapNode } from "../../tools/guards/sprites";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "../../ui/shadcn/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/shadcn/ui/popover";
-import { isAbstractMesh, isAnyTransformNode, isCamera, isCollisionInstancedMesh, isCollisionMesh, isInstancedMesh, isLight, isMesh, isNode } from "../../tools/guards/nodes";
+import { isAbstractMesh, isAnyTransformNode, isCamera, isCollisionInstancedMesh, isCollisionMesh, isLight, isNode } from "../../tools/guards/nodes";
import { EditorCamera } from "../nodes/camera";
diff --git a/editor/src/editor/layout/preview/import/import.ts b/editor/src/editor/layout/preview/import/import.ts
index b5bc3273d..abc2d41c9 100644
--- a/editor/src/editor/layout/preview/import/import.ts
+++ b/editor/src/editor/layout/preview/import/import.ts
@@ -22,6 +22,9 @@ import {
HDRCubeTexture,
} from "babylonjs";
+// Registers SceneLoader plugins for .glb / .gltf (and other formats); required for ImportMeshAsync on those paths.
+import "babylonjs-loaders";
+
import { UniqueNumber } from "../../../../tools/tools";
import { isMesh } from "../../../../tools/guards/nodes";
import { isSprite } from "../../../../tools/guards/sprites";
diff --git a/editor/src/editor/windows/effect-editor/animation.tsx b/editor/src/editor/windows/effect-editor/animation.tsx
new file mode 100644
index 000000000..8724ee478
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/animation.tsx
@@ -0,0 +1,18 @@
+import { Component, ReactNode } from "react";
+import { IEffectEditor } from ".";
+
+export interface IEffectEditorAnimationProps {
+ filePath: string | null;
+ editor: IEffectEditor;
+}
+
+export class EffectEditorAnimation extends Component {
+ public render(): ReactNode {
+ return (
+
+
Animation Panel
+
Animation timeline will be displayed here
+
+ );
+ }
+}
diff --git a/editor/src/editor/windows/effect-editor/editors/bezier.tsx b/editor/src/editor/windows/effect-editor/editors/bezier.tsx
new file mode 100644
index 000000000..65f727714
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/editors/bezier.tsx
@@ -0,0 +1,498 @@
+import { Component, ReactNode, PointerEvent } from "react";
+import { EditorInspectorNumberField } from "../../../layout/inspector/fields/number";
+import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../../../ui/shadcn/ui/dropdown-menu";
+import { HiOutlineArrowPath } from "react-icons/hi2";
+import { Button } from "../../../../ui/shadcn/ui/button";
+import type { FunctionEditorValue } from "./function";
+
+export interface IBezierCurve {
+ p0: number;
+ p1: number;
+ p2: number;
+ p3: number;
+ start: number;
+}
+
+export interface IBezierEditorProps {
+ value: FunctionEditorValue | null | undefined;
+ onChange: () => void;
+}
+
+export interface IBezierEditorState {
+ curve: IBezierCurve;
+ dragging: boolean;
+ dragPoint: "p0" | "p1" | "p2" | "p3" | null;
+ hoveredPoint: "p0" | "p1" | "p2" | "p3" | null;
+ width: number;
+ height: number;
+}
+
+type CurvePreset = "linear" | "easeIn" | "easeOut" | "easeInOut" | "easeInBack" | "easeOutBack";
+
+const CURVE_PRESETS: Record = {
+ linear: { p0: 0, p1: 0, p2: 1, p3: 1, start: 0 },
+ easeIn: { p0: 0, p1: 0.42, p2: 1, p3: 1, start: 0 },
+ easeOut: { p0: 0, p1: 0, p2: 0.58, p3: 1, start: 0 },
+ easeInOut: { p0: 0, p1: 0.42, p2: 0.58, p3: 1, start: 0 },
+ easeInBack: { p0: 0, p1: -0.36, p2: 0.36, p3: 1, start: 0 },
+ easeOutBack: { p0: 0, p1: 0.64, p2: 1.36, p3: 1, start: 0 },
+};
+
+export class BezierEditor extends Component {
+ private static _gradientIdSeq = 0;
+
+ private readonly _fillGradientId = `bezier-editor-fill-${BezierEditor._gradientIdSeq++}`;
+
+ private _svgRef: SVGSVGElement | null = null;
+ private _containerRef: HTMLDivElement | null = null;
+
+ /** Synchronous drag target so pointermove runs before dragging state is committed in React. */
+ private _activeDragPoint: "p0" | "p1" | "p2" | "p3" | null = null;
+
+ public constructor(props: IBezierEditorProps) {
+ super(props);
+ this.state = {
+ curve: this._getCurveFromValue(),
+ dragging: false,
+ dragPoint: null,
+ hoveredPoint: null,
+ width: 400,
+ height: 250,
+ };
+ }
+
+ public componentDidMount(): void {
+ this._updateDimensions();
+ window.addEventListener("resize", this._updateDimensions);
+ }
+
+ public componentWillUnmount(): void {
+ window.removeEventListener("resize", this._updateDimensions);
+ }
+
+ private _updateDimensions = (): void => {
+ if (this._containerRef) {
+ const rect = this._containerRef.getBoundingClientRect();
+ this.setState({
+ width: Math.max(300, rect.width - 20),
+ height: 250,
+ });
+ }
+ };
+
+ private _getCurveFromValue(): IBezierCurve {
+ if (!this.props.value || !this.props.value.data) {
+ return CURVE_PRESETS.linear;
+ }
+ const data = this.props.value.data as Record;
+ const fn = data.function as { p0?: number; p1?: number; p2?: number; p3?: number } | undefined;
+ if (fn) {
+ return {
+ p0: fn.p0 ?? 0,
+ p1: fn.p1 ?? 1.0 / 3,
+ p2: fn.p2 ?? (1.0 / 3) * 2,
+ p3: fn.p3 ?? 1,
+ start: 0,
+ };
+ }
+
+ return CURVE_PRESETS.linear;
+ }
+
+ private _saveCurveToValue(): void {
+ if (!this.props.value) {
+ return;
+ }
+
+ if (!this.props.value.data) {
+ this.props.value.data = {};
+ }
+ const data = this.props.value.data as Record;
+
+ // Save as direct function object (not array). Quarks Bezier.genValue uses raw coefficients — no clamp.
+ const { p0, p1, p2, p3 } = this.state.curve;
+ data.function = {
+ p0: Number.isFinite(p0) ? p0 : 0,
+ p1: Number.isFinite(p1) ? p1 : 1 / 3,
+ p2: Number.isFinite(p2) ? p2 : (1.0 / 3) * 2,
+ p3: Number.isFinite(p3) ? p3 : 1,
+ };
+ }
+
+ /** Keep the same `curve` object reference so EditorInspectorNumberField pointer-drag listeners stay on the mutated object. */
+ private _onCurveNumberChange = (): void => {
+ this.setState((s) => ({ curve: s.curve }));
+ this._saveCurveToValue();
+ this.props.onChange();
+ };
+
+ private _applyPreset(preset: CurvePreset): void {
+ const presetCurve = CURVE_PRESETS[preset];
+ this.setState({ curve: { ...presetCurve } }, () => {
+ this._saveCurveToValue();
+ this.props.onChange();
+ });
+ }
+
+ private _screenToSvg(clientX: number, clientY: number): { x: number; y: number } {
+ if (!this._svgRef) {
+ return { x: 0, y: 0 };
+ }
+
+ const rect = this._svgRef.getBoundingClientRect();
+ const vb = this._svgRef.viewBox?.baseVal;
+ if (!vb) {
+ return {
+ x: clientX - rect.left,
+ y: clientY - rect.top,
+ };
+ }
+
+ const scaleX = rect.width / vb.width;
+ const scaleY = rect.height / vb.height;
+ const useScale = Math.min(scaleX, scaleY);
+
+ const offsetX = (rect.width - vb.width * useScale) / 2;
+ const offsetY = (rect.height - vb.height * useScale) / 2;
+
+ return {
+ x: (clientX - rect.left - offsetX) / useScale,
+ y: (clientY - rect.top - offsetY) / useScale,
+ };
+ }
+
+ /** Scalar range mapped to the vertical chart (includes overshoot from control points). */
+ private _valueSpan(curve: IBezierCurve): { lo: number; hi: number } {
+ const pad = 0.12;
+ let lo = Math.min(0, 1, curve.p0, curve.p1, curve.p2, curve.p3) - pad;
+ let hi = Math.max(0, 1, curve.p0, curve.p1, curve.p2, curve.p3) + pad;
+ const span = hi - lo;
+ const minSpan = 0.6;
+ if (span < minSpan) {
+ const mid = (lo + hi) / 2;
+ lo = mid - minSpan / 2;
+ hi = mid + minSpan / 2;
+ }
+ return { lo, hi };
+ }
+
+ private _valueToSvgY(value: number, curve: IBezierCurve): number {
+ const { lo, hi } = this._valueSpan(curve);
+ const padding = this.state.height * 0.1;
+ const range = this.state.height * 0.8;
+ const t = (value - lo) / (hi - lo);
+ return padding + (1 - t) * range;
+ }
+
+ private _svgYToValue(svgY: number, curve: IBezierCurve): number {
+ const { lo, hi } = this._valueSpan(curve);
+ const padding = this.state.height * 0.1;
+ const range = this.state.height * 0.8;
+ const t = (this.state.height - svgY - padding) / range;
+ return lo + t * (hi - lo);
+ }
+
+ private _handlePointerDown = (ev: PointerEvent, point: "p0" | "p1" | "p2" | "p3"): void => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ if (ev.button !== 0) {
+ return;
+ }
+
+ this._activeDragPoint = point;
+ this.setState({
+ dragging: true,
+ dragPoint: point,
+ });
+ ev.currentTarget.setPointerCapture(ev.pointerId);
+ };
+
+ private _handlePointerMove = (ev: PointerEvent): void => {
+ if (!this._activeDragPoint) {
+ return;
+ }
+
+ const svgPos = this._screenToSvg(ev.clientX, ev.clientY);
+ const value = this._svgYToValue(svgPos.y, this.state.curve);
+ const curve = { ...this.state.curve };
+ curve[this._activeDragPoint] = value;
+ this.setState({ curve });
+ };
+
+ private _finishPointerDrag = (ev: PointerEvent): void => {
+ if (!this._activeDragPoint) {
+ return;
+ }
+ this._activeDragPoint = null;
+ try {
+ ev.currentTarget.releasePointerCapture(ev.pointerId);
+ } catch {
+ // Capture may already be released.
+ }
+
+ this._saveCurveToValue();
+ this.props.onChange();
+
+ this.setState({
+ dragging: false,
+ dragPoint: null,
+ });
+ };
+
+ private _renderCurve(curve: IBezierCurve): ReactNode {
+ const p0X = 0;
+ const p0Y = this._valueToSvgY(curve.p0, curve);
+ const p1X = this.state.width / 3;
+ const p1Y = this._valueToSvgY(curve.p1, curve);
+ const p2X = (this.state.width * 2) / 3;
+ const p2Y = this._valueToSvgY(curve.p2, curve);
+ const p3X = this.state.width;
+ const p3Y = this._valueToSvgY(curve.p3, curve);
+
+ const strokePath = `M ${p0X} ${p0Y} C ${p1X} ${p1Y} ${p2X} ${p2Y} ${p3X} ${p3Y}`;
+ const fillPath = `${strokePath} L ${this.state.width} ${this.state.height} L 0 ${this.state.height} Z`;
+
+ const isHovered = (point: "p0" | "p1" | "p2" | "p3") => this.state.hoveredPoint === point || this.state.dragPoint === point;
+ const pointRadius = (point: "p0" | "p1" | "p2" | "p3") => (point === "p0" || point === "p3" ? 5 : 4);
+ const pointColor = (point: "p0" | "p1" | "p2" | "p3") => (point === "p0" || point === "p3" ? "#2563eb" : "#7c3aed");
+
+ return (
+
+
+
+
+
+
+
+
+ {/* Filled area under curve */}
+
+
+ {/* Curve line */}
+
+
+ {/* Control lines */}
+
+
+
+ {/* Control points */}
+ {(["p0", "p1", "p2", "p3"] as const).map((point) => {
+ const x = point === "p0" ? p0X : point === "p1" ? p1X : point === "p2" ? p2X : p3X;
+ const y = point === "p0" ? p0Y : point === "p1" ? p1Y : point === "p2" ? p2Y : p3Y;
+ const value = curve[point];
+ const radius = pointRadius(point);
+ const color = pointColor(point);
+
+ return (
+
+ this._handlePointerDown(e, point)}
+ onPointerMove={this._handlePointerMove}
+ onPointerUp={this._finishPointerDrag}
+ onPointerCancel={this._finishPointerDrag}
+ onPointerEnter={() => this.setState({ hoveredPoint: point })}
+ onPointerLeave={() => {
+ if (!this._activeDragPoint) {
+ this.setState({ hoveredPoint: null });
+ }
+ }}
+ />
+ {/* Value label */}
+ {isHovered(point) && (
+
+ {value.toFixed(2)}
+
+ )}
+
+ );
+ })}
+
+ );
+ }
+
+ private _renderGrid(): ReactNode {
+ const gridLines: ReactNode[] = [];
+ const curve = this.state.curve;
+ const { lo, hi } = this._valueSpan(curve);
+
+ // Horizontal grid lines (value markers)
+ for (let i = 0; i <= 10; i++) {
+ const value = lo + ((hi - lo) * i) / 10;
+ const y = this._valueToSvgY(value, curve);
+ const isMainLine = i % 5 === 0;
+
+ gridLines.push(
+
+
+ {isMainLine && (
+
+ {value.toFixed(1)}
+
+ )}
+ ,
+ );
+ }
+
+ // Vertical grid lines (time markers)
+ for (let i = 0; i <= 10; i++) {
+ const t = i / 10;
+ const x = t * this.state.width;
+ const isMainLine = i % 5 === 0;
+
+ gridLines.push(
+
+
+ {isMainLine && (
+
+ {t.toFixed(1)}
+
+ )}
+
+ );
+ }
+
+ // Reference line at scalar 0.5 when visible in the current span
+ if (0.5 > lo && 0.5 < hi) {
+ gridLines.push(
+ ,
+ );
+ }
+
+ return {gridLines} ;
+ }
+
+ public render(): ReactNode {
+ return (
+ (this._containerRef = ref)} className="flex flex-col gap-3 w-full">
+ {/* Toolbar */}
+
+
+ Curve Editor
+
+
+
+
+
+ Presets
+
+
+
+ this._applyPreset("linear")}>Linear
+ this._applyPreset("easeIn")}>Ease In
+ this._applyPreset("easeOut")}>Ease Out
+ this._applyPreset("easeInOut")}>Ease In-Out
+ this._applyPreset("easeInBack")}>Ease In Back
+ this._applyPreset("easeOutBack")}>Ease Out Back
+
+
+ this._applyPreset("linear")} title="Reset to Linear">
+
+
+
+
+
+ {/* SVG Canvas */}
+
+
(this._svgRef = ref)}
+ width={this.state.width}
+ height={this.state.height}
+ viewBox={`0 0 ${this.state.width} ${this.state.height}`}
+ className="w-full h-full touch-none"
+ style={{ background: "var(--background)", touchAction: "none" }}
+ >
+ {/* Grid */}
+ {this._renderGrid()}
+
+ {/* Curve */}
+ {this._renderCurve(this.state.curve)}
+
+
+ {/* Axis labels */}
+
Time
+
Value
+
+
+ {/* Value inputs */}
+
+
+
+ Start (P0)
+
+
+
+ Control 1 (P1)
+
+
+
+ Control 2 (P2)
+
+
+
+ End (P3)
+
+
+
+
+
+ );
+ }
+}
diff --git a/editor/src/editor/windows/effect-editor/editors/color-function.tsx b/editor/src/editor/windows/effect-editor/editors/color-function.tsx
new file mode 100644
index 000000000..bad35a149
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/editors/color-function.tsx
@@ -0,0 +1,256 @@
+import { ReactNode } from "react";
+import { Color4 } from "@babylonjs/core/Maths/math.color";
+
+import { EditorInspectorColorField } from "../../../layout/inspector/fields/color";
+import { EditorInspectorColorGradientField } from "../../../layout/inspector/fields/gradient";
+import { EditorInspectorListField } from "../../../layout/inspector/fields/list";
+import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block";
+import type { IGradientKey } from "../../../../ui/gradient-picker";
+
+export type ColorFunctionType = "ConstantColor" | "ColorRange" | "Gradient" | "RandomColor" | "RandomColorBetweenGradient";
+export type ColorFunctionEditorValue = {
+ colorFunctionType?: ColorFunctionType;
+ data?: Record;
+};
+
+export interface IColorFunctionEditorProps {
+ value: ColorFunctionEditorValue | null | undefined;
+ onChange: () => void;
+ label: string;
+}
+
+export function ColorFunctionEditor(props: IColorFunctionEditorProps): ReactNode {
+ const { value, onChange, label } = props;
+ if (!value) {
+ return null;
+ }
+ if (!value.colorFunctionType) {
+ value.colorFunctionType = "ConstantColor";
+ }
+ if (!value.data) {
+ value.data = {};
+ }
+ const data = value.data as Record;
+ const functionType = value.colorFunctionType as ColorFunctionType;
+
+ const typeItems = [
+ { text: "Color", value: "ConstantColor" },
+ { text: "Color Range", value: "ColorRange" },
+ { text: "Gradient", value: "Gradient" },
+ { text: "Random Color", value: "RandomColor" },
+ { text: "Random Between Gradient", value: "RandomColorBetweenGradient" },
+ ];
+
+ return (
+ <>
+ {
+ // Reset data when type changes and initialize defaults
+ const newType = value.colorFunctionType;
+ value.data = {};
+ if (newType === "ConstantColor") {
+ data.color = new Color4(1, 1, 1, 1);
+ } else if (newType === "ColorRange") {
+ data.colorA = new Color4(0, 0, 0, 1);
+ data.colorB = new Color4(1, 1, 1, 1);
+ } else if (newType === "Gradient") {
+ data.colorKeys = [
+ { pos: 0, value: [0, 0, 0, 1] },
+ { pos: 1, value: [1, 1, 1, 1] },
+ ];
+ data.alphaKeys = [
+ { pos: 0, value: 1 },
+ { pos: 1, value: 1 },
+ ];
+ } else if (newType === "RandomColor") {
+ data.colorA = new Color4(0, 0, 0, 1);
+ data.colorB = new Color4(1, 1, 1, 1);
+ } else if (newType === "RandomColorBetweenGradient") {
+ data.gradient1 = {
+ colorKeys: [
+ { pos: 0, value: [0, 0, 0, 1] },
+ { pos: 1, value: [1, 1, 1, 1] },
+ ],
+ alphaKeys: [
+ { pos: 0, value: 1 },
+ { pos: 1, value: 1 },
+ ],
+ };
+ data.gradient2 = {
+ colorKeys: [
+ { pos: 0, value: [1, 0, 0, 1] },
+ { pos: 1, value: [0, 1, 0, 1] },
+ ],
+ alphaKeys: [
+ { pos: 0, value: 1 },
+ { pos: 1, value: 1 },
+ ],
+ };
+ }
+ onChange();
+ }}
+ />
+
+ {functionType === "ConstantColor" && (
+ <>
+ {!data.color && (data.color = new Color4(1, 1, 1, 1))}
+
+ >
+ )}
+
+ {functionType === "ColorRange" && (
+ <>
+ {!data.colorA && (data.colorA = new Color4(0, 0, 0, 1))}
+ {!data.colorB && (data.colorB = new Color4(1, 1, 1, 1))}
+
+
+ >
+ )}
+
+ {functionType === "Gradient" &&
+ (() => {
+ const convertColorKeys = (keys: IGradientKey[] | undefined): IGradientKey[] => {
+ if (!keys || keys.length === 0) {
+ return [
+ { pos: 0, value: [0, 0, 0, 1] },
+ { pos: 1, value: [1, 1, 1, 1] },
+ ];
+ }
+ return keys.map((key) => ({
+ pos: key.pos ?? 0,
+ value: Array.isArray(key.value)
+ ? key.value
+ : typeof key.value === "object" && "r" in key.value
+ ? [key.value.r, key.value.g, key.value.b, key.value.a ?? 1]
+ : [0, 0, 0, 1],
+ }));
+ };
+
+ const convertAlphaKeys = (keys: IGradientKey[] | undefined): IGradientKey[] => {
+ if (!keys || keys.length === 0) {
+ return [
+ { pos: 0, value: 1 },
+ { pos: 1, value: 1 },
+ ];
+ }
+ return keys.map((key) => ({
+ pos: key.pos ?? 0,
+ value: typeof key.value === "number" ? key.value : 1,
+ }));
+ };
+
+ const wrapperGradient = {
+ colorKeys: convertColorKeys(data.colorKeys as IGradientKey[] | undefined),
+ alphaKeys: convertAlphaKeys(data.alphaKeys as IGradientKey[] | undefined),
+ };
+
+ return (
+ {
+ data.colorKeys = newColorKeys;
+ data.alphaKeys = newAlphaKeys;
+ onChange();
+ }}
+ />
+ );
+ })()}
+
+ {functionType === "RandomColor" && (
+ <>
+ {!data.colorA && (data.colorA = new Color4(0, 0, 0, 1))}
+ {!data.colorB && (data.colorB = new Color4(1, 1, 1, 1))}
+
+
+ >
+ )}
+
+ {functionType === "RandomColorBetweenGradient" &&
+ (() => {
+ const convertColorKeys = (keys: IGradientKey[] | undefined): IGradientKey[] => {
+ if (!keys || keys.length === 0) {
+ return [
+ { pos: 0, value: [0, 0, 0, 1] },
+ { pos: 1, value: [1, 1, 1, 1] },
+ ];
+ }
+ return keys.map((key) => ({
+ pos: key.pos ?? 0,
+ value: Array.isArray(key.value)
+ ? key.value
+ : typeof key.value === "object" && "r" in key.value
+ ? [key.value.r, key.value.g, key.value.b, key.value.a ?? 1]
+ : [0, 0, 0, 1],
+ }));
+ };
+
+ const convertAlphaKeys = (keys: IGradientKey[] | undefined): IGradientKey[] => {
+ if (!keys || keys.length === 0) {
+ return [
+ { pos: 0, value: 1 },
+ { pos: 1, value: 1 },
+ ];
+ }
+ return keys.map((key) => ({
+ pos: key.pos ?? 0,
+ value: typeof key.value === "number" ? key.value : 1,
+ }));
+ };
+
+ if (!data.gradient1) {
+ data.gradient1 = {};
+ }
+ if (!data.gradient2) {
+ data.gradient2 = {};
+ }
+
+ const wrapperGradient1 = {
+ colorKeys: convertColorKeys((data.gradient1 as Record).colorKeys as IGradientKey[] | undefined),
+ alphaKeys: convertAlphaKeys((data.gradient1 as Record).alphaKeys as IGradientKey[] | undefined),
+ };
+
+ const wrapperGradient2 = {
+ colorKeys: convertColorKeys((data.gradient2 as Record).colorKeys as IGradientKey[] | undefined),
+ alphaKeys: convertAlphaKeys((data.gradient2 as Record).alphaKeys as IGradientKey[] | undefined),
+ };
+
+ return (
+ <>
+
+ Gradient 1
+ {
+ (data.gradient1 as Record).colorKeys = newColorKeys;
+ (data.gradient1 as Record).alphaKeys = newAlphaKeys;
+ onChange();
+ }}
+ />
+
+
+ Gradient 2
+ {
+ (data.gradient2 as Record).colorKeys = newColorKeys;
+ (data.gradient2 as Record).alphaKeys = newAlphaKeys;
+ onChange();
+ }}
+ />
+
+ >
+ );
+ })()}
+ >
+ );
+}
diff --git a/editor/src/editor/windows/effect-editor/editors/color.tsx b/editor/src/editor/windows/effect-editor/editors/color.tsx
new file mode 100644
index 000000000..e72895daf
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/editors/color.tsx
@@ -0,0 +1,377 @@
+import { ReactNode } from "react";
+import { Color4 } from "@babylonjs/core/Maths/math.color";
+
+import { EditorInspectorColorField } from "../../../layout/inspector/fields/color";
+import { EditorInspectorColorGradientField } from "../../../layout/inspector/fields/gradient";
+import { EditorInspectorListField } from "../../../layout/inspector/fields/list";
+import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block";
+
+import { type Color, parseConstantColor } from "../types";
+import type { EditorColorArray } from "../quarks-adapter";
+
+export type EffectColorType = "ConstantColor" | "ColorRange" | "Gradient" | "RandomColor" | "RandomColorBetweenGradient";
+
+function toEditorColorKeys(keys: Array<{ pos?: number; value: unknown }>): Array<{ pos: number; value: EditorColorArray }> {
+ return keys.map((key) => ({
+ pos: Number(key.pos ?? 0),
+ value: Array.isArray(key.value) ? [Number(key.value[0] ?? 0), Number(key.value[1] ?? 0), Number(key.value[2] ?? 0), Number(key.value[3] ?? 1)] : [0, 0, 0, 1],
+ }));
+}
+
+function toEditorAlphaKeys(keys: Array<{ pos?: number; value: unknown }> | undefined): Array<{ pos: number; value: number }> {
+ return (keys ?? []).map((key) => ({
+ pos: Number(key.pos ?? 0),
+ value: Number(key.value ?? 1),
+ }));
+}
+
+export interface IEffectColorEditorProps {
+ value: Color | undefined;
+ onChange: (newValue: Color) => void;
+ label?: string;
+}
+
+/**
+ * Editor for VEffectColor (ConstantColor, ColorRange, Gradient, RandomColor, RandomColorBetweenGradient)
+ * Works directly with VEffectColor types, not wrappers
+ */
+export function EffectColorEditor(props: IEffectColorEditorProps): ReactNode {
+ const { value, onChange, label } = props;
+
+ // Determine current type from value
+ let currentType: EffectColorType = "ConstantColor";
+ if (value) {
+ if (typeof value === "string" || Array.isArray(value)) {
+ currentType = "ConstantColor";
+ } else if ("type" in value) {
+ if (value.type === "ConstantColor") {
+ currentType = "ConstantColor";
+ } else if (value.type === "ColorRange") {
+ currentType = "ColorRange";
+ } else if (value.type === "Gradient") {
+ currentType = "Gradient";
+ } else if (value.type === "RandomColor") {
+ currentType = "RandomColor";
+ } else if (value.type === "RandomColorBetweenGradient") {
+ currentType = "RandomColorBetweenGradient";
+ }
+ }
+ }
+
+ const typeItems = [
+ { text: "Color", value: "ConstantColor" },
+ { text: "Color Range", value: "ColorRange" },
+ { text: "Gradient", value: "Gradient" },
+ { text: "Random Color", value: "RandomColor" },
+ { text: "Random Between Gradient", value: "RandomColorBetweenGradient" },
+ ];
+
+ // Wrapper object for EditorInspectorListField
+ const wrapper = {
+ get type() {
+ return currentType;
+ },
+ set type(newType: EffectColorType) {
+ currentType = newType;
+ // Convert value to new type
+ let newValue: Color;
+ const currentColor = value ? parseConstantColor(value) : new Color4(1, 1, 1, 1);
+ if (newType === "ConstantColor") {
+ newValue = { type: "ConstantColor", color: [currentColor.r, currentColor.g, currentColor.b, currentColor.a] };
+ } else if (newType === "ColorRange") {
+ newValue = {
+ type: "ColorRange",
+ a: [currentColor.r, currentColor.g, currentColor.b, currentColor.a],
+ b: [1, 1, 1, 1],
+ };
+ } else if (newType === "Gradient") {
+ newValue = {
+ type: "Gradient",
+ colorKeys: [
+ { pos: 0, value: [currentColor.r, currentColor.g, currentColor.b, currentColor.a] },
+ { pos: 1, value: [1, 1, 1, 1] },
+ ],
+ alphaKeys: [
+ { pos: 0, value: currentColor.a },
+ { pos: 1, value: 1 },
+ ],
+ };
+ } else if (newType === "RandomColor") {
+ newValue = {
+ type: "RandomColor",
+ a: [currentColor.r, currentColor.g, currentColor.b, currentColor.a],
+ b: [1, 1, 1, 1],
+ };
+ } else {
+ // RandomColorBetweenGradient
+ newValue = {
+ type: "RandomColorBetweenGradient",
+ gradient1: {
+ type: "Gradient",
+ colorKeys: [
+ { pos: 0, value: [currentColor.r, currentColor.g, currentColor.b, currentColor.a] },
+ { pos: 1, value: [1, 1, 1, 1] },
+ ],
+ alphaKeys: [
+ { pos: 0, value: currentColor.a },
+ { pos: 1, value: 1 },
+ ],
+ },
+ gradient2: {
+ type: "Gradient",
+ colorKeys: [
+ { pos: 0, value: [1, 0, 0, 1] },
+ { pos: 1, value: [0, 1, 0, 1] },
+ ],
+ alphaKeys: [
+ { pos: 0, value: 1 },
+ { pos: 1, value: 1 },
+ ],
+ },
+ };
+ }
+ onChange(newValue);
+ },
+ };
+
+ return (
+ <>
+ {
+ // Type change is handled by setter
+ }}
+ />
+
+ {currentType === "ConstantColor" && (
+ <>
+ {(() => {
+ const constantColor = value ? parseConstantColor(value) : new Color4(1, 1, 1, 1);
+ const wrapperColor = {
+ get color() {
+ return constantColor;
+ },
+ set color(newColor: Color4) {
+ onChange({ type: "ConstantColor", color: [newColor.r, newColor.g, newColor.b, newColor.a] });
+ },
+ };
+ return (
+ {
+ wrapperColor.color = c as Color4;
+ }}
+ />
+ );
+ })()}
+ >
+ )}
+
+ {currentType === "ColorRange" && (
+ <>
+ {(() => {
+ const colorRange = value && typeof value === "object" && "type" in value && value.type === "ColorRange" ? value : null;
+ const colorA = colorRange ? new Color4(colorRange.a[0], colorRange.a[1], colorRange.a[2], colorRange.a[3]) : new Color4(0, 0, 0, 1);
+ const colorB = colorRange ? new Color4(colorRange.b[0], colorRange.b[1], colorRange.b[2], colorRange.b[3]) : new Color4(1, 1, 1, 1);
+ const wrapperRange = {
+ get colorA() {
+ return colorA;
+ },
+ set colorA(newColor: Color4) {
+ const currentB = colorRange ? colorRange.b : [1, 1, 1, 1];
+ onChange({ type: "ColorRange", a: [newColor.r, newColor.g, newColor.b, newColor.a], b: currentB as [number, number, number, number] });
+ },
+ get colorB() {
+ return colorB;
+ },
+ set colorB(newColor: Color4) {
+ const currentA = colorRange ? colorRange.a : [0, 0, 0, 1];
+ onChange({
+ type: "ColorRange",
+ a: currentA as [number, number, number, number],
+ b: [newColor.r, newColor.g, newColor.b, newColor.a] as [number, number, number, number],
+ });
+ },
+ };
+ return (
+ <>
+ {
+ wrapperRange.colorA = c as Color4;
+ }}
+ />
+ {
+ wrapperRange.colorB = c as Color4;
+ }}
+ />
+ >
+ );
+ })()}
+ >
+ )}
+
+ {currentType === "Gradient" &&
+ (() => {
+ const gradientValue = value && typeof value === "object" && "type" in value && value.type === "Gradient" ? value : null;
+ const defaultColorKeys = [
+ { pos: 0, value: [0, 0, 0, 1] },
+ { pos: 1, value: [1, 1, 1, 1] },
+ ];
+ const defaultAlphaKeys = [
+ { pos: 0, value: 1 },
+ { pos: 1, value: 1 },
+ ];
+ const wrapperGradient = {
+ colorKeys: gradientValue?.colorKeys || defaultColorKeys,
+ alphaKeys: gradientValue?.alphaKeys || defaultAlphaKeys,
+ };
+ return (
+ {
+ onChange({
+ type: "Gradient",
+ colorKeys: toEditorColorKeys(newColorKeys),
+ alphaKeys: toEditorAlphaKeys(newAlphaKeys),
+ });
+ }}
+ />
+ );
+ })()}
+
+ {currentType === "RandomColor" && (
+ <>
+ {(() => {
+ const randomColor = value && typeof value === "object" && "type" in value && value.type === "RandomColor" ? value : null;
+ const colorA = randomColor ? new Color4(randomColor.a[0], randomColor.a[1], randomColor.a[2], randomColor.a[3]) : new Color4(0, 0, 0, 1);
+ const colorB = randomColor ? new Color4(randomColor.b[0], randomColor.b[1], randomColor.b[2], randomColor.b[3]) : new Color4(1, 1, 1, 1);
+ const wrapperRandom = {
+ get colorA() {
+ return colorA;
+ },
+ set colorA(newColor: Color4) {
+ const currentB = randomColor ? randomColor.b : [1, 1, 1, 1];
+ onChange({ type: "RandomColor", a: [newColor.r, newColor.g, newColor.b, newColor.a], b: currentB as [number, number, number, number] });
+ },
+ get colorB() {
+ return colorB;
+ },
+ set colorB(newColor: Color4) {
+ const currentA = randomColor ? randomColor.a : [0, 0, 0, 1];
+ onChange({
+ type: "RandomColor",
+ a: currentA as [number, number, number, number],
+ b: [newColor.r, newColor.g, newColor.b, newColor.a] as [number, number, number, number],
+ });
+ },
+ };
+ return (
+ <>
+ {
+ wrapperRandom.colorA = c as Color4;
+ }}
+ />
+ {
+ wrapperRandom.colorB = c as Color4;
+ }}
+ />
+ >
+ );
+ })()}
+ >
+ )}
+
+ {currentType === "RandomColorBetweenGradient" &&
+ (() => {
+ const randomGradient = value && typeof value === "object" && "type" in value && value.type === "RandomColorBetweenGradient" ? value : null;
+ const defaultColorKeys = [
+ { pos: 0, value: [0, 0, 0, 1] },
+ { pos: 1, value: [1, 1, 1, 1] },
+ ];
+ const defaultAlphaKeys = [
+ { pos: 0, value: 1 },
+ { pos: 1, value: 1 },
+ ];
+
+ const wrapperGradient1 = {
+ colorKeys: randomGradient?.gradient1?.colorKeys || defaultColorKeys,
+ alphaKeys: randomGradient?.gradient1?.alphaKeys || defaultAlphaKeys,
+ };
+
+ const wrapperGradient2 = {
+ colorKeys: randomGradient?.gradient2?.colorKeys || defaultColorKeys,
+ alphaKeys: randomGradient?.gradient2?.alphaKeys || defaultAlphaKeys,
+ };
+
+ return (
+ <>
+
+ Gradient 1
+ {
+ if (randomGradient) {
+ onChange({
+ type: "RandomColorBetweenGradient",
+ gradient1: {
+ type: "Gradient",
+ colorKeys: toEditorColorKeys(newColorKeys),
+ alphaKeys: toEditorAlphaKeys(newAlphaKeys),
+ },
+ gradient2: randomGradient.gradient2,
+ });
+ }
+ }}
+ />
+
+
+ Gradient 2
+ {
+ if (randomGradient) {
+ onChange({
+ type: "RandomColorBetweenGradient",
+ gradient1: randomGradient.gradient1,
+ gradient2: {
+ type: "Gradient",
+ colorKeys: toEditorColorKeys(newColorKeys),
+ alphaKeys: toEditorAlphaKeys(newAlphaKeys),
+ },
+ });
+ }
+ }}
+ />
+
+ >
+ );
+ })()}
+ >
+ );
+}
diff --git a/editor/src/editor/windows/effect-editor/editors/function.tsx b/editor/src/editor/windows/effect-editor/editors/function.tsx
new file mode 100644
index 000000000..1a0d079e1
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/editors/function.tsx
@@ -0,0 +1,128 @@
+import { ReactNode } from "react";
+
+import { EditorInspectorNumberField } from "../../../layout/inspector/fields/number";
+import { EditorInspectorListField } from "../../../layout/inspector/fields/list";
+import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block";
+
+import { BezierEditor } from "./bezier";
+
+export type FunctionType = "ConstantValue" | "IntervalValue" | "PiecewiseBezier" | "Vector3Function";
+export type FunctionEditorValue = {
+ functionType?: FunctionType;
+ data?: Record;
+};
+
+export interface IFunctionEditorProps {
+ value: FunctionEditorValue | null | undefined;
+ onChange: () => void;
+ availableTypes?: FunctionType[];
+ label: string;
+}
+
+export function FunctionEditor(props: IFunctionEditorProps): ReactNode {
+ const { value, onChange, availableTypes, label } = props;
+ const types = availableTypes || ["ConstantValue", "IntervalValue", "PiecewiseBezier", "Vector3Function"];
+ if (!value) {
+ return null;
+ }
+ if (!value.functionType) {
+ value.functionType = types[0];
+ }
+ if (!value.data) {
+ value.data = {};
+ }
+ const functionType = value.functionType as FunctionType;
+
+ const typeItems = types.map((type) => ({
+ text: type,
+ value: type,
+ }));
+
+ return (
+ <>
+ {
+ const newType = value.functionType as FunctionType;
+ value.data = {};
+ if (newType === "ConstantValue") {
+ (value.data as Record).value = 1.0;
+ } else if (newType === "IntervalValue") {
+ (value.data as Record).min = 0;
+ (value.data as Record).max = 1;
+ } else if (newType === "PiecewiseBezier") {
+ (value.data as Record).function = { p0: 0, p1: 1.0 / 3, p2: (1.0 / 3) * 2, p3: 1 };
+ } else if (newType === "Vector3Function") {
+ (value.data as Record).x = { functionType: "ConstantValue", data: { value: 0 } };
+ (value.data as Record).y = { functionType: "ConstantValue", data: { value: 0 } };
+ (value.data as Record).z = { functionType: "ConstantValue", data: { value: 0 } };
+ }
+ onChange();
+ }}
+ />
+
+ {functionType === "ConstantValue" && (
+ <>
+ {(value.data as Record).value === undefined && ((value.data as Record).value = 1.0)}
+
+ >
+ )}
+
+ {functionType === "IntervalValue" && (
+ <>
+ {(value.data as Record).min === undefined && ((value.data as Record).min = 0)}
+ {(value.data as Record).max === undefined && ((value.data as Record).max = 1)}
+
+ {label ? "Range" : ""}
+
+
+
+
+
+ >
+ )}
+
+ {functionType === "PiecewiseBezier" && (
+ <>
+ {!(value.data as Record).function && ((value.data as Record).function = { p0: 0, p1: 1.0 / 3, p2: (1.0 / 3) * 2, p3: 1 })}
+
+ >
+ )}
+
+ {functionType === "Vector3Function" && (
+ <>
+
+ X
+ ).x as FunctionEditorValue | undefined) ?? { functionType: "ConstantValue", data: { value: 0 } }}
+ onChange={onChange}
+ availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]}
+ label=""
+ />
+
+
+ Y
+ ).y as FunctionEditorValue | undefined) ?? { functionType: "ConstantValue", data: { value: 0 } }}
+ onChange={onChange}
+ availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]}
+ label=""
+ />
+
+
+ Z
+ ).z as FunctionEditorValue | undefined) ?? { functionType: "ConstantValue", data: { value: 0 } }}
+ onChange={onChange}
+ availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]}
+ label=""
+ />
+
+ >
+ )}
+ >
+ );
+}
diff --git a/editor/src/editor/windows/effect-editor/editors/index.ts b/editor/src/editor/windows/effect-editor/editors/index.ts
new file mode 100644
index 000000000..15478b643
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/editors/index.ts
@@ -0,0 +1,6 @@
+export * from "./value";
+export * from "./color";
+export * from "./rotation";
+export * from "./function";
+export * from "./color-function";
+export * from "./bezier";
diff --git a/editor/src/editor/windows/effect-editor/editors/rotation.tsx b/editor/src/editor/windows/effect-editor/editors/rotation.tsx
new file mode 100644
index 000000000..b8c061138
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/editors/rotation.tsx
@@ -0,0 +1,312 @@
+import { ReactNode } from "react";
+
+import { EditorInspectorListField } from "../../../layout/inspector/fields/list";
+import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block";
+
+import { type Rotation, parseConstantValue, type Value } from "../types";
+import { EffectValueEditor } from "./value";
+
+export type EffectRotationType = Rotation["type"];
+
+export interface IEffectRotationEditorProps {
+ value: Rotation | undefined;
+ onChange: (newValue: Rotation) => void;
+ label?: string;
+}
+
+/**
+ * Editor for VEffectRotation (Euler, AxisAngle, RandomQuat)
+ * Works directly with VEffectRotation types, not wrappers
+ */
+export function EffectRotationEditor(props: IEffectRotationEditorProps): ReactNode {
+ const { value, onChange, label } = props;
+
+ // Determine current type from value
+ let currentType: EffectRotationType = "Euler";
+ if (value) {
+ if (typeof value === "object" && "type" in value) {
+ if (value.type === "Euler") {
+ currentType = "Euler";
+ } else if (value.type === "AxisAngle") {
+ currentType = "AxisAngle";
+ } else if (value.type === "RandomQuat") {
+ currentType = "RandomQuat";
+ }
+ }
+ }
+
+ const typeItems = [
+ { text: "Euler", value: "Euler" },
+ { text: "Axis Angle", value: "AxisAngle" },
+ { text: "Random Quat", value: "RandomQuat" },
+ ];
+
+ // Wrapper object for EditorInspectorListField
+ const wrapper = {
+ get type() {
+ return currentType;
+ },
+ set type(newType: EffectRotationType) {
+ currentType = newType;
+ // Convert value to new type
+ let newValue: Rotation;
+ if (newType === "Euler") {
+ // Convert current value to Euler
+ if (value && typeof value === "object" && "type" in value && value.type === "Euler") {
+ newValue = value;
+ } else {
+ const angleZ = value ? parseConstantValue(value as unknown as Value) : 0;
+ newValue = {
+ type: "Euler",
+ angleZ: { type: "ConstantValue", value: angleZ },
+ order: "xyz",
+ };
+ }
+ } else if (newType === "AxisAngle") {
+ // Convert to AxisAngle
+ const angle = value ? parseConstantValue(value as unknown as Value) : 0;
+ newValue = {
+ type: "AxisAngle",
+ x: { type: "ConstantValue", value: 0 },
+ y: { type: "ConstantValue", value: 0 },
+ z: { type: "ConstantValue", value: 1 },
+ angle: { type: "ConstantValue", value: angle },
+ };
+ } else {
+ // RandomQuat
+ newValue = { type: "RandomQuat" };
+ }
+ onChange(newValue);
+ },
+ };
+
+ return (
+ <>
+ {
+ // Type change is handled by setter
+ }}
+ />
+
+ {currentType === "Euler" && (
+ <>
+ {(() => {
+ const eulerValue = value && typeof value === "object" && "type" in value && value.type === "Euler" ? value : null;
+ const angleX = eulerValue?.angleX || { type: "ConstantValue" as const, value: 0 };
+ const angleY = eulerValue?.angleY || { type: "ConstantValue" as const, value: 0 };
+ const angleZ = eulerValue?.angleZ || { type: "ConstantValue" as const, value: 0 };
+ const order = eulerValue?.order || "xyz";
+
+ const orderWrapper = {
+ get order() {
+ return order;
+ },
+ set order(newOrder: "xyz" | "zyx") {
+ if (eulerValue) {
+ onChange({ ...eulerValue, order: newOrder });
+ } else {
+ onChange({
+ type: "Euler",
+ angleX,
+ angleY,
+ angleZ,
+ order: newOrder,
+ });
+ }
+ },
+ };
+
+ return (
+ <>
+ {}}
+ />
+
+ Angle X
+ {
+ if (eulerValue) {
+ onChange({ ...eulerValue, angleX: newAngleX as Value });
+ } else {
+ onChange({
+ type: "Euler",
+ angleX: newAngleX as Value,
+ angleY,
+ angleZ,
+ order,
+ });
+ }
+ }}
+ availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]}
+ step={0.1}
+ />
+
+
+ Angle Y
+ {
+ if (eulerValue) {
+ onChange({ ...eulerValue, angleY: newAngleY as Value });
+ } else {
+ onChange({
+ type: "Euler",
+ angleX,
+ angleY: newAngleY as Value,
+ angleZ,
+ order,
+ });
+ }
+ }}
+ availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]}
+ step={0.1}
+ />
+
+
+ Angle Z
+ {
+ if (eulerValue) {
+ onChange({ ...eulerValue, angleZ: newAngleZ as Value });
+ } else {
+ onChange({
+ type: "Euler",
+ angleX,
+ angleY,
+ angleZ: newAngleZ as Value,
+ order,
+ });
+ }
+ }}
+ availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]}
+ step={0.1}
+ />
+
+ >
+ );
+ })()}
+ >
+ )}
+
+ {currentType === "AxisAngle" && (
+ <>
+ {(() => {
+ const axisAngleValue = value && typeof value === "object" && "type" in value && value.type === "AxisAngle" ? value : null;
+ const x = axisAngleValue?.x || { type: "ConstantValue" as const, value: 0 };
+ const y = axisAngleValue?.y || { type: "ConstantValue" as const, value: 0 };
+ const z = axisAngleValue?.z || { type: "ConstantValue" as const, value: 1 };
+ const angle = axisAngleValue?.angle || { type: "ConstantValue" as const, value: 0 };
+
+ return (
+ <>
+
+ Axis X
+ {
+ if (axisAngleValue) {
+ onChange({ ...axisAngleValue, x: newX as Value });
+ } else {
+ onChange({
+ type: "AxisAngle",
+ x: newX as Value,
+ y,
+ z,
+ angle,
+ });
+ }
+ }}
+ availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]}
+ step={0.1}
+ />
+
+
+ Axis Y
+ {
+ if (axisAngleValue) {
+ onChange({ ...axisAngleValue, y: newY as Value });
+ } else {
+ onChange({
+ type: "AxisAngle",
+ x,
+ y: newY as Value,
+ z,
+ angle,
+ });
+ }
+ }}
+ availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]}
+ step={0.1}
+ />
+
+
+ Axis Z
+ {
+ if (axisAngleValue) {
+ onChange({ ...axisAngleValue, z: newZ as Value });
+ } else {
+ onChange({
+ type: "AxisAngle",
+ x,
+ y,
+ z: newZ as Value,
+ angle,
+ });
+ }
+ }}
+ availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]}
+ step={0.1}
+ />
+
+
+ Angle
+ {
+ if (axisAngleValue) {
+ onChange({ ...axisAngleValue, angle: newAngle as Value });
+ } else {
+ onChange({
+ type: "AxisAngle",
+ x,
+ y,
+ z,
+ angle: newAngle as Value,
+ });
+ }
+ }}
+ availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]}
+ step={0.1}
+ />
+
+ >
+ );
+ })()}
+ >
+ )}
+
+ {currentType === "RandomQuat" && (
+ <>
+ Random quaternion rotation will be applied to each particle
+ >
+ )}
+ >
+ );
+}
diff --git a/editor/src/editor/windows/effect-editor/editors/value.tsx b/editor/src/editor/windows/effect-editor/editors/value.tsx
new file mode 100644
index 000000000..0b5ffcd48
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/editors/value.tsx
@@ -0,0 +1,314 @@
+import { ReactNode } from "react";
+
+import { EditorInspectorNumberField } from "../../../layout/inspector/fields/number";
+import { EditorInspectorListField } from "../../../layout/inspector/fields/list";
+import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block";
+
+import { type Value, parseConstantValue, parseIntervalValue } from "../types";
+
+type PiecewiseBezier = Extract;
+import { BezierEditor } from "./bezier";
+
+type BezierFunction = { p0: number; p1: number; p2: number; p3: number };
+
+// Vec3Function is a custom editor extension, not part of the core Value type
+export type EffectValueType = Value["type"] | PiecewiseBezier["type"] | "Vec3Function";
+
+export interface IVec3Function {
+ type: "Vec3Function";
+ x: Value;
+ y: Value;
+ z: Value;
+}
+
+export interface IEffectValueEditorProps {
+ value: Value | IVec3Function | undefined;
+ onChange: (newValue: Value | IVec3Function) => void;
+ label?: string;
+ availableTypes?: EffectValueType[];
+ min?: number;
+ step?: number;
+}
+
+/**
+ * Editor for VEffectValue (ConstantValue, IntervalValue, PiecewiseBezier, Vec3Function)
+ * Works directly with VEffectValue types, not wrappers
+ */
+export function EffectValueEditor(props: IEffectValueEditorProps): ReactNode {
+ const { value, onChange, label, availableTypes, min, step } = props;
+
+ const types = availableTypes || ["ConstantValue", "IntervalValue", "PiecewiseBezier"];
+
+ // Determine current type from value
+ let currentType: EffectValueType = "ConstantValue";
+ if (value) {
+ if ("type" in value) {
+ if (value.type === "Vec3Function") {
+ currentType = "Vec3Function";
+ } else if (value.type === "ConstantValue") {
+ currentType = "ConstantValue";
+ } else if (value.type === "IntervalValue") {
+ currentType = "IntervalValue";
+ } else if (value.type === "PiecewiseBezier") {
+ currentType = "PiecewiseBezier";
+ }
+ }
+ }
+
+ const typeItems = types.map((type) => ({
+ text: type,
+ value: type,
+ }));
+
+ // Wrapper object for EditorInspectorListField
+ const wrapper = {
+ get type() {
+ return currentType;
+ },
+ set type(newType: EffectValueType) {
+ currentType = newType;
+ // Convert value to new type
+ let newValue: Value | IVec3Function;
+ if (newType === "ConstantValue") {
+ const currentValue = value && "type" in value && value.type !== "Vec3Function" ? parseConstantValue(value as Value) : 1;
+ newValue = { type: "ConstantValue", value: currentValue };
+ } else if (newType === "IntervalValue") {
+ const interval = value && "type" in value && value.type !== "Vec3Function" ? parseIntervalValue(value as Value) : { min: 0, max: 1 };
+ newValue = { type: "IntervalValue", a: interval.min, b: interval.max };
+ } else if (newType === "Vec3Function") {
+ const currentValue = value && "type" in value && value.type !== "Vec3Function" ? parseConstantValue(value as Value) : 1;
+ newValue = {
+ type: "Vec3Function",
+ x: { type: "ConstantValue", value: currentValue },
+ y: { type: "ConstantValue", value: currentValue },
+ z: { type: "ConstantValue", value: currentValue },
+ };
+ } else {
+ // PiecewiseBezier - convert from current value
+ const currentValue = value && "type" in value && value.type !== "Vec3Function" ? parseConstantValue(value as Value) : 1;
+ newValue = {
+ type: "PiecewiseBezier",
+ functions: [
+ {
+ function: { p0: currentValue, p1: currentValue, p2: currentValue, p3: currentValue },
+ start: 0,
+ },
+ ],
+ };
+ }
+ onChange(newValue);
+ },
+ };
+
+ return (
+ <>
+ {
+ // Type change is handled by setter
+ }}
+ />
+
+ {currentType === "ConstantValue" && (
+ <>
+ {(() => {
+ const constantValue = value && "type" in value && value.type !== "Vec3Function" ? parseConstantValue(value as Value) : 1;
+ const wrapperValue = {
+ get value() {
+ return constantValue;
+ },
+ set value(newVal: number) {
+ onChange({ type: "ConstantValue", value: newVal });
+ },
+ };
+ return (
+ {
+ // Value change is handled by setter
+ }}
+ />
+ );
+ })()}
+ >
+ )}
+
+ {currentType === "IntervalValue" && (
+ <>
+ {(() => {
+ const interval = value && "type" in value && value.type !== "Vec3Function" ? parseIntervalValue(value as Value) : { min: 0, max: 1 };
+ const wrapperInterval = {
+ get min() {
+ return interval.min;
+ },
+ set min(newMin: number) {
+ const currentMax = value && "type" in value && value.type === "IntervalValue" ? value.b : interval.max;
+ onChange({ type: "IntervalValue", a: newMin, b: currentMax });
+ },
+ get max() {
+ return interval.max;
+ },
+ set max(newMax: number) {
+ const currentMin = value && "type" in value && value.type === "IntervalValue" ? value.a : interval.min;
+ onChange({ type: "IntervalValue", a: currentMin, b: newMax });
+ },
+ };
+ return (
+
+ {label ? "Range" : ""}
+
+ {
+ // Value change is handled by setter
+ }}
+ />
+ {
+ // Value change is handled by setter
+ }}
+ />
+
+
+ );
+ })()}
+ >
+ )}
+
+ {currentType === "PiecewiseBezier" && (
+ <>
+ {(() => {
+ // Convert VEffectValue to wrapper format for BezierEditor
+ const bezierValue = value && typeof value !== "number" && "type" in value && value.type === "PiecewiseBezier" ? value : null;
+ const wrapperBezier = {
+ get functionType() {
+ return "PiecewiseBezier";
+ },
+ set functionType(_: string) {},
+ get data() {
+ if (!bezierValue || bezierValue.functions.length === 0) {
+ return {
+ function: { p0: 1, p1: 1.0 / 3, p2: (1.0 / 3) * 2, p3: 1 },
+ };
+ }
+ // Use first function for editing
+ return {
+ function: bezierValue.functions[0].function,
+ };
+ },
+ set data(newData: { function?: BezierFunction }) {
+ // Update first function or create new
+ if (!bezierValue) {
+ onChange({
+ type: "PiecewiseBezier",
+ functions: [
+ {
+ function: newData.function || { p0: 1, p1: 1.0 / 3, p2: (1.0 / 3) * 2, p3: 1 },
+ start: 0,
+ },
+ ],
+ });
+ } else {
+ const newFunctions = [...bezierValue.functions];
+ newFunctions[0] = {
+ ...newFunctions[0],
+ function: newData.function || newFunctions[0].function,
+ };
+ onChange({
+ type: "PiecewiseBezier",
+ functions: newFunctions,
+ });
+ }
+ },
+ };
+ return {}} />;
+ })()}
+ >
+ )}
+
+ {currentType === "Vec3Function" && (
+ <>
+ {(() => {
+ const vec3Value = value && typeof value !== "number" && "type" in value && value.type === "Vec3Function" ? value : null;
+ const currentX = vec3Value ? vec3Value.x : { type: "ConstantValue" as const, value: 1 };
+ const currentY = vec3Value ? vec3Value.y : { type: "ConstantValue" as const, value: 1 };
+ const currentZ = vec3Value ? vec3Value.z : { type: "ConstantValue" as const, value: 1 };
+ return (
+ <>
+
+ X
+ {
+ onChange({
+ type: "Vec3Function",
+ x: newX as Value,
+ y: currentY,
+ z: currentZ,
+ });
+ }}
+ availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]}
+ min={min}
+ step={step}
+ />
+
+
+ Y
+ {
+ onChange({
+ type: "Vec3Function",
+ x: currentX,
+ y: newY as Value,
+ z: currentZ,
+ });
+ }}
+ availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]}
+ min={min}
+ step={step}
+ />
+
+
+ Z
+ {
+ onChange({
+ type: "Vec3Function",
+ x: currentX,
+ y: currentY,
+ z: newZ as Value,
+ });
+ }}
+ availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]}
+ min={min}
+ step={step}
+ />
+
+ >
+ );
+ })()}
+ >
+ )}
+ >
+ );
+}
diff --git a/editor/src/editor/windows/effect-editor/graph.tsx b/editor/src/editor/windows/effect-editor/graph.tsx
new file mode 100644
index 000000000..5755d4a26
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/graph.tsx
@@ -0,0 +1,761 @@
+import { Component, ReactNode } from "react";
+import { Tree, TreeNodeInfo } from "@blueprintjs/core";
+import { readJSON, writeJSON } from "fs-extra";
+import { basename } from "path";
+import { toast } from "sonner";
+import { AiOutlineClose, AiOutlinePlus } from "react-icons/ai";
+import { HiOutlineFolder } from "react-icons/hi2";
+import { IoSparklesSharp } from "react-icons/io5";
+
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+ ContextMenuTrigger,
+} from "../../../ui/shadcn/ui/context-menu";
+import { saveSingleFileDialog } from "../../../tools/dialog";
+import { IEffectEditor } from ".";
+import { ParticleSystem, QuarksUtil } from "babylon.quarks";
+import { TransformNode } from "@babylonjs/core/Meshes/transformNode";
+import { IQuarksEffectFile, IQuarksNode, QuarksEffectDocument, getQuarksTransformUuid } from "./quarks-bridge";
+import { applyQuarksLoadedInspectorHints } from "./quarks-inspector-hints";
+import { flushQuarksParticleBatchGeometry } from "./quarks-mesh-geometry";
+
+export type PlaybackState = "playing" | "paused" | "stopped" | "unavailable";
+type StoredPlaybackState = Exclude;
+
+export interface IPlaybackControlState {
+ state: PlaybackState;
+ canPlayPause: boolean;
+ canStop: boolean;
+ canRestart: boolean;
+ reason?: string;
+}
+
+export interface IEffectEditorGraphProps {
+ filePath: string | null;
+ onNodeSelected?: (nodeId: string | number | null) => void;
+ editor: IEffectEditor;
+}
+
+export interface IEffectEditorGraphState {
+ nodes: TreeNodeInfo[];
+ selectedNodeId: string | number | null;
+}
+
+export class EffectEditorGraph extends Component {
+ private _effects: Map = new Map();
+ private _nodeIndex: Map = new Map();
+ private _playbackByUuid: Map = new Map();
+ /** User expand/collapse only; `_rebuildTree` must not reset expansion. */
+ private _userTreeExpandOverride: Map = new Map();
+
+ public constructor(props: IEffectEditorGraphProps) {
+ super(props);
+ this.state = {
+ nodes: [],
+ selectedNodeId: null,
+ };
+ }
+
+ /** Returns the first effect document for fallback flows. */
+ public getEffect(): QuarksEffectDocument | null {
+ return this._effects.values().next().value ?? null;
+ }
+
+ /** Returns all effect documents currently loaded in graph. */
+ public getAllEffects(): QuarksEffectDocument[] {
+ return Array.from(this._effects.values());
+ }
+
+ /** Returns node data currently shown in tree by generated node id. */
+ public getNodeData(nodeId: string | number): IQuarksNode | null {
+ return this._nodeIndex.get(String(nodeId)) ?? null;
+ }
+
+ /** Returns transform node backing selected tree node. */
+ public getNodeTransform(nodeId: string | number): TransformNode | null {
+ const node = this.getNodeData(nodeId);
+ if (!node) {
+ return null;
+ }
+
+ if (node.type === "particle") {
+ return (node.data as ParticleSystem).emitter as TransformNode;
+ }
+
+ return node.data as TransformNode;
+ }
+
+ /** Returns particle system backing selected tree node if it is a particle node. */
+ public getNodeSystem(nodeId: string | number): ParticleSystem | null {
+ const node = this.getNodeData(nodeId);
+ if (!node || node.type !== "particle") {
+ return null;
+ }
+
+ return node.data as ParticleSystem;
+ }
+
+ /** Loads quarks-native effect file into graph. */
+ public async loadFromFile(filePath: string): Promise {
+ if (!this.props.editor.preview?.scene) {
+ return;
+ }
+
+ try {
+ const json = await readJSON(filePath);
+ this._disposeAllEffects();
+ this._playbackByUuid.clear();
+
+ const effects = this._normalizeImportedEffects(json);
+ if (effects.length === 0) {
+ this._createDefaultEffectDocument(basename(filePath, ".fx") || "Effect");
+ this._rebuildTree();
+ this._notifyUiStateChanged();
+ return;
+ }
+
+ for (const effect of effects) {
+ const document = QuarksEffectDocument.fromQuarksJson(this.props.editor.preview.scene, effect.data, effect.name);
+ document.stop();
+ this._effects.set(document.id, document);
+ this._registerEffectNaturalIdle(document);
+ this._setNodePlaybackState(document.toNodeTree(), "stopped");
+ this._flushParticleBatchGeometryForDocument(document);
+ applyQuarksLoadedInspectorHints(document.root);
+ }
+
+ this._rebuildTree();
+ this._notifyUiStateChanged();
+ } catch (error) {
+ toast.error(`Failed to load effect file: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+
+ /** Loads plain quarks JSON into graph as a new document. */
+ public async loadFromQuarksFile(filePath: string): Promise {
+ if (!this.props.editor.preview?.scene) {
+ return;
+ }
+
+ try {
+ const json = await readJSON(filePath);
+ const document = QuarksEffectDocument.fromQuarksJson(this.props.editor.preview.scene, json, basename(filePath, ".json") || "Effect");
+ document.stop();
+ this._effects.set(document.id, document);
+ this._registerEffectNaturalIdle(document);
+ this._setNodePlaybackState(document.toNodeTree(), "stopped");
+ this._flushParticleBatchGeometryForDocument(document);
+ applyQuarksLoadedInspectorHints(document.root);
+ this._rebuildTree();
+ this._notifyUiStateChanged();
+ } catch (error) {
+ toast.error(`Failed to import quarks file: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+
+ /** Unity import path is removed in Quarks-native pipeline. */
+ public async loadFromUnityData(): Promise {
+ toast.error("Unity import is removed in Quarks-native effect editor.");
+ }
+
+ /** Serializes documents into the new quarks-native editor file format. */
+ public serializeToFileFormat(): IQuarksEffectFile {
+ return {
+ version: "quarks-editor-1",
+ effects: this.getAllEffects().map((effect) => effect.serialize()),
+ };
+ }
+
+ /** Toggles selected node between playing and paused state. */
+ public toggleNodePlayback(nodeId: string | number): boolean {
+ const control = this.getNodePlaybackControlState(nodeId);
+ if (!control.canPlayPause) {
+ return false;
+ }
+ const state = control.state;
+ if (state === "playing") {
+ this.pauseNode(nodeId);
+ return false;
+ }
+
+ this.playNode(nodeId);
+ return true;
+ }
+
+ /** Plays selected node or entire effect root. */
+ public playNode(nodeId: string | number): void {
+ const node = this.getNodeData(nodeId);
+ if (!node) {
+ return;
+ }
+ if (!this._nodeHasParticleSystems(node)) {
+ return;
+ }
+
+ const effect = this._findEffectForNode(node);
+ if (!effect) {
+ return;
+ }
+
+ const transform = this.getNodeTransform(nodeId);
+ if (!transform) {
+ return;
+ }
+
+ effect.resetNaturalIdleTracking(transform);
+ effect.playNode(transform);
+ this._setNodePlaybackState(node, "playing");
+ this._rebuildTree();
+ this._notifyUiStateChanged();
+ }
+
+ /** Pauses selected node or entire effect root. */
+ public pauseNode(nodeId: string | number): void {
+ const node = this.getNodeData(nodeId);
+ if (!node) {
+ return;
+ }
+ if (!this._nodeHasParticleSystems(node)) {
+ return;
+ }
+
+ const effect = this._findEffectForNode(node);
+ if (!effect) {
+ return;
+ }
+
+ const transform = this.getNodeTransform(nodeId);
+ if (!transform) {
+ return;
+ }
+
+ effect.pauseNode(transform);
+ this._setNodePlaybackState(node, "paused");
+ this._rebuildTree();
+ this._notifyUiStateChanged();
+ }
+
+ /** Stops selected node or entire effect root. */
+ public stopNode(nodeId: string | number): void {
+ const node = this.getNodeData(nodeId);
+ if (!node) {
+ return;
+ }
+ if (!this._nodeHasParticleSystems(node)) {
+ return;
+ }
+
+ const effect = this._findEffectForNode(node);
+ if (!effect) {
+ return;
+ }
+
+ const transform = this.getNodeTransform(nodeId);
+ if (!transform) {
+ return;
+ }
+
+ effect.resetNaturalIdleTracking(transform);
+ effect.stopNode(transform);
+ this._setNodePlaybackState(node, "stopped");
+ this._rebuildTree();
+ this._notifyUiStateChanged();
+ }
+
+ /** Restarts selected node or entire effect root. */
+ public restartNode(nodeId: string | number): void {
+ const node = this.getNodeData(nodeId);
+ if (!node) {
+ return;
+ }
+ if (!this._nodeHasParticleSystems(node)) {
+ return;
+ }
+
+ const effect = this._findEffectForNode(node);
+ const transform = this.getNodeTransform(nodeId);
+ if (!effect || !transform) {
+ return;
+ }
+
+ effect.resetNaturalIdleTracking(transform);
+ effect.restartNode(transform);
+ this._setNodePlaybackState(node, "playing");
+ this._rebuildTree();
+ this._notifyUiStateChanged();
+ }
+
+ /** Returns selected node playback state. */
+ public getNodePlaybackState(nodeId: string | number): PlaybackState {
+ const node = this.getNodeData(nodeId);
+ if (!node) {
+ return "unavailable";
+ }
+ if (!this._nodeHasParticleSystems(node)) {
+ return "unavailable";
+ }
+ return this._resolveAggregatePlaybackState(node);
+ }
+
+ /** Returns preview-control availability and disabled reason for selected node. */
+ public getNodePlaybackControlState(nodeId: string | number | null | undefined): IPlaybackControlState {
+ if (nodeId === null || nodeId === undefined) {
+ return {
+ state: "unavailable",
+ canPlayPause: false,
+ canStop: false,
+ canRestart: false,
+ reason: "Select a node to control playback.",
+ };
+ }
+ const node = this.getNodeData(nodeId);
+ if (!node) {
+ return {
+ state: "unavailable",
+ canPlayPause: false,
+ canStop: false,
+ canRestart: false,
+ reason: "Selected node is no longer available.",
+ };
+ }
+ if (!this._nodeHasParticleSystems(node)) {
+ return {
+ state: "unavailable",
+ canPlayPause: false,
+ canStop: false,
+ canRestart: false,
+ reason: "No particle systems in selected node.",
+ };
+ }
+ const state = this._resolveAggregatePlaybackState(node);
+ return {
+ state,
+ canPlayPause: true,
+ canStop: state === "playing" || state === "paused",
+ canRestart: true,
+ };
+ }
+
+ /** Backward-compatible boolean playback selector. */
+ public isNodePlaying(nodeId: string | number): boolean {
+ return this.getNodePlaybackState(nodeId) === "playing";
+ }
+
+ /** Refreshes labels when node names are changed. */
+ public updateNodeNames(): void {
+ this._rebuildTree();
+ }
+
+ public componentWillUnmount(): void {
+ this._disposeAllEffects();
+ }
+
+ public render(): ReactNode {
+ const hasNodes = this.state.nodes.length > 0;
+
+ return (
+
+ {hasNodes ? (
+
+ this._handleNodeExpanded(n)}
+ onNodeCollapse={(n) => this._handleNodeCollapsed(n)}
+ onNodeClick={(n) => this._handleNodeClicked(n)}
+ />
+
+ ) : (
+
+
+
+
+
No particles. Right-click to add.
+
+
+
+
+
+ Add
+
+
+ this._handleCreateEffect()}>
+ Effect
+
+
+
+
+
+
+ )}
+
+ );
+ }
+
+ /** Ensures batch meshes exist for paused systems after load so Renderer inspector can bind materials. */
+ private _flushParticleBatchGeometryForDocument(document: QuarksEffectDocument): void {
+ QuarksUtil.runOnAllParticleEmitters(document.root, (emitter) => {
+ flushQuarksParticleBatchGeometry(emitter.system as ParticleSystem);
+ });
+ }
+
+ /** Rebuilds tree from live quarks document roots. */
+ private _rebuildTree(): void {
+ const nodes: TreeNodeInfo[] = [];
+ this._nodeIndex.clear();
+
+ for (const effect of this._effects.values()) {
+ const root = effect.toNodeTree();
+ nodes.push(this._convertNode(root, true));
+ }
+
+ const prevSelected = this.state.selectedNodeId;
+ const selectionStillValid = prevSelected !== null && prevSelected !== undefined && this._nodeIndex.has(String(prevSelected));
+ const nextSelectedId = selectionStillValid ? prevSelected : null;
+
+ const nodesWithSelection = nextSelectedId !== null && nextSelectedId !== undefined ? this._setNodeSelected(nodes, nextSelectedId) : nodes;
+
+ this.setState({ nodes: nodesWithSelection, selectedNodeId: nextSelectedId }, () => {
+ if (!selectionStillValid && prevSelected !== null && prevSelected !== undefined) {
+ this.props.onNodeSelected?.(null);
+ }
+ });
+ }
+
+ /** Expanded state: user overrides win; otherwise match previous default (root + groups open). */
+ private _getTreeNodeIsExpanded(nodeId: string | number, isEffectRoot: boolean, nodeType: IQuarksNode["type"]): boolean {
+ const key = String(nodeId);
+ if (this._userTreeExpandOverride.has(key)) {
+ return this._userTreeExpandOverride.get(key)!;
+ }
+ return isEffectRoot || nodeType === "group";
+ }
+
+ /** Converts internal node model into Blueprint tree data. */
+ private _convertNode(node: IQuarksNode, isEffectRoot: boolean): TreeNodeInfo {
+ this._nodeIndex.set(node.id, node);
+ const isParticle = node.type === "particle";
+ const icon = isParticle ? (
+
+ ) : (
+
+ );
+
+ return {
+ id: node.id,
+ label: this._getNodeLabelComponent(node),
+ icon,
+ isExpanded: this._getTreeNodeIsExpanded(node.id, isEffectRoot, node.type),
+ hasCaret: node.children.length > 0 || node.type === "group",
+ nodeData: node,
+ childNodes: node.children.map((child) => this._convertNode(child, false)),
+ };
+ }
+
+ /** Detects current effect document by any node in its hierarchy. */
+ private _findEffectForNode(node: IQuarksNode): QuarksEffectDocument | null {
+ for (const effect of this._effects.values()) {
+ const tree = effect.toNodeTree();
+ if (this._containsNode(tree, node.uuid)) {
+ return effect;
+ }
+ }
+ return null;
+ }
+
+ /** Finds whether tree contains node uuid. */
+ private _containsNode(node: IQuarksNode, uuid: string): boolean {
+ if (node.uuid === uuid) {
+ return true;
+ }
+ return node.children.some((child) => this._containsNode(child, uuid));
+ }
+
+ /** Creates an empty effect document in active preview scene. */
+ private _handleCreateEffect(): void {
+ if (!this.props.editor.preview?.scene) {
+ return;
+ }
+
+ this._createDefaultEffectDocument(`Effect ${this._effects.size + 1}`);
+ this._rebuildTree();
+ this._notifyUiStateChanged();
+ }
+
+ /** Adds a particle system under selected group node. */
+ private _handleAddParticleSystemToNode(node: IQuarksNode): void {
+ const effect = this._findEffectForNode(node);
+ const parent = this.getNodeTransform(node.id);
+ if (!effect || !parent) {
+ return;
+ }
+
+ effect.createParticle(parent);
+ this._setNodePlaybackState(node, "stopped");
+ this._rebuildTree();
+ this._notifyUiStateChanged();
+ }
+
+ /** Adds a transform group under selected group node. */
+ private _handleAddGroupToNode(node: IQuarksNode): void {
+ const effect = this._findEffectForNode(node);
+ const parent = this.getNodeTransform(node.id);
+ if (!effect || !parent) {
+ return;
+ }
+
+ effect.createGroup(parent);
+ this._setNodePlaybackState(node, "stopped");
+ this._rebuildTree();
+ this._notifyUiStateChanged();
+ }
+
+ /** Deletes selected node or whole effect when deleting root. */
+ private _handleDeleteNode(node: IQuarksNode): void {
+ const effect = this._findEffectForNode(node);
+ if (!effect) {
+ return;
+ }
+
+ if (node.uuid === effect.id) {
+ effect.dispose();
+ this._effects.delete(effect.id);
+ this._clearNodePlaybackState(node);
+ this._rebuildTree();
+ this._notifyUiStateChanged();
+ return;
+ }
+
+ const transform = this.getNodeTransform(node.id);
+ if (!transform || !transform.parent || !(transform.parent instanceof TransformNode)) {
+ return;
+ }
+
+ effect.removeNode(transform);
+ this._clearNodePlaybackState(node);
+ this._rebuildTree();
+ this._notifyUiStateChanged();
+ }
+
+ /** Exports a single effect as quarks JSON payload. */
+ private async _handleExportEffect(node: IQuarksNode): Promise {
+ const effect = this._findEffectForNode(node);
+ if (!effect) {
+ return;
+ }
+
+ const filePath = saveSingleFileDialog({
+ title: "Export Effect",
+ filters: [{ name: "Quarks Files", extensions: ["json"] }],
+ defaultPath: `${effect.name}.json`,
+ });
+
+ if (!filePath) {
+ return;
+ }
+
+ await writeJSON(filePath, effect.serialize().data, { spaces: "\t", encoding: "utf-8" });
+ toast.success("Effect exported");
+ }
+
+ /** Node context menu label with CRUD operations. */
+ private _getNodeLabelComponent(node: IQuarksNode): JSX.Element {
+ const playbackState = this.getNodePlaybackState(node.id);
+ const stateLabel = playbackState === "playing" ? "Playing" : playbackState === "paused" ? "Paused" : playbackState === "stopped" ? "Stopped" : "Unavailable";
+ const stateClassName =
+ playbackState === "playing" ? "bg-emerald-400" : playbackState === "paused" ? "bg-amber-400" : playbackState === "stopped" ? "bg-slate-400" : "bg-red-400";
+
+ return (
+
+
+
+
+ {node.name}
+
+
+
+ {node.type === "group" && (
+ <>
+
+
+ Add
+
+
+ this._handleAddParticleSystemToNode(node)}>
+ Particle
+
+ this._handleAddGroupToNode(node)}>
+ Group
+
+
+
+
+ >
+ )}
+ {this._effects.has(node.uuid) && (
+ <>
+ this._handleExportEffect(node)}>Export
+
+ >
+ )}
+ this._handleDeleteNode(node)}>
+ Delete
+
+
+
+ );
+ }
+
+ /** Expands tree node preserving existing tree state. */
+ private _handleNodeExpanded(node: TreeNodeInfo): void {
+ this._userTreeExpandOverride.set(String(node.id), true);
+ this.setState({ nodes: this._setNodeExpanded(this.state.nodes, node.id, true) });
+ }
+
+ /** Collapses tree node preserving existing tree state. */
+ private _handleNodeCollapsed(node: TreeNodeInfo): void {
+ this._userTreeExpandOverride.set(String(node.id), false);
+ this.setState({ nodes: this._setNodeExpanded(this.state.nodes, node.id, false) });
+ }
+
+ /** Selects tree node and notifies external tabs. */
+ private _handleNodeClicked(node: TreeNodeInfo): void {
+ const selectedNodeId = node.id as string;
+ this.setState({ nodes: this._setNodeSelected(this.state.nodes, selectedNodeId), selectedNodeId });
+ this.props.onNodeSelected?.(selectedNodeId);
+ }
+
+ /** Updates expand/collapse state recursively for tree node id. */
+ private _setNodeExpanded(nodes: TreeNodeInfo[], id: string | number, value: boolean): TreeNodeInfo[] {
+ return nodes.map((node) => ({
+ ...node,
+ isExpanded: node.id === id ? value : node.isExpanded,
+ childNodes: node.childNodes ? this._setNodeExpanded(node.childNodes, id, value) : undefined,
+ }));
+ }
+
+ /** Updates selected tree node recursively for tree node id. */
+ private _setNodeSelected(nodes: TreeNodeInfo[], id: string | number): TreeNodeInfo[] {
+ return nodes.map((node) => ({
+ ...node,
+ isSelected: node.id === id,
+ childNodes: node.childNodes ? this._setNodeSelected(node.childNodes, id) : undefined,
+ }));
+ }
+
+ /** Disposes every loaded effect when switching files/unmounting. */
+ private _disposeAllEffects(): void {
+ for (const effect of this._effects.values()) {
+ effect.dispose();
+ }
+ this._effects.clear();
+ this._playbackByUuid.clear();
+ this._userTreeExpandOverride.clear();
+ this._notifyUiStateChanged();
+ }
+
+ /** Notifies external panels that graph/runtime state changed outside their React state. */
+ private _notifyUiStateChanged(): void {
+ this.props.editor.preview?.forceUpdate();
+ this.props.editor.layout?.forceUpdate();
+ }
+
+ /** Applies playback state recursively to node and descendants. */
+ private _setNodePlaybackState(node: IQuarksNode, state: StoredPlaybackState): void {
+ this._playbackByUuid.set(node.uuid, state);
+ for (const child of node.children) {
+ this._setNodePlaybackState(child, state);
+ }
+ }
+
+ /** Clears playback state recursively for removed node subtree. */
+ private _clearNodePlaybackState(node: IQuarksNode): void {
+ this._playbackByUuid.delete(node.uuid);
+ for (const child of node.children) {
+ this._clearNodePlaybackState(child);
+ }
+ }
+
+ /** Finds whether node subtree has at least one particle system. */
+ private _nodeHasParticleSystems(node: IQuarksNode): boolean {
+ if (node.type === "particle") {
+ return true;
+ }
+ return node.children.some((child) => this._nodeHasParticleSystems(child));
+ }
+
+ /** Collects stored playback states for all particle descendants. */
+ private _collectParticlePlaybackStates(node: IQuarksNode, out: StoredPlaybackState[]): void {
+ if (node.type === "particle") {
+ out.push(this._playbackByUuid.get(node.uuid) ?? "stopped");
+ return;
+ }
+ for (const child of node.children) {
+ this._collectParticlePlaybackStates(child, out);
+ }
+ }
+
+ /** Resolves aggregate playback state for group/effect selections. */
+ private _resolveAggregatePlaybackState(node: IQuarksNode): StoredPlaybackState {
+ const states: StoredPlaybackState[] = [];
+ this._collectParticlePlaybackStates(node, states);
+ if (states.some((s) => s === "playing")) {
+ return "playing";
+ }
+ if (states.some((s) => s === "paused")) {
+ return "paused";
+ }
+ return "stopped";
+ }
+
+ /** Normalizes import payloads into list of quarks object roots. */
+ private _normalizeImportedEffects(json: any): Array<{ name: string; data: any }> {
+ if (json?.version === "quarks-editor-1" && Array.isArray(json.effects)) {
+ return json.effects.map((entry: any, index: number) => ({
+ name: entry?.name || `Effect ${index + 1}`,
+ data: entry?.data,
+ }));
+ }
+
+ if (json?.object) {
+ return [
+ {
+ name: json?.object?.name || "Effect",
+ data: json,
+ },
+ ];
+ }
+
+ throw new Error("Unsupported effect file format");
+ }
+
+ /** Subscribes to Quarks emitEnd + last particle death so non-looping playback clears "playing" in the tree. */
+ private _registerEffectNaturalIdle(document: QuarksEffectDocument): void {
+ document.setNaturalIdleHandler((emitter) => {
+ const uuid = getQuarksTransformUuid(emitter);
+ this._playbackByUuid.set(uuid, "stopped");
+ this._rebuildTree();
+ this._notifyUiStateChanged();
+ });
+ }
+
+ /** Creates a valid default Quarks document with one editable particle system. */
+ private _createDefaultEffectDocument(name: string): QuarksEffectDocument {
+ if (!this.props.editor.preview?.scene) {
+ throw new Error("Preview scene is not ready");
+ }
+
+ const document = QuarksEffectDocument.createEmpty(this.props.editor.preview.scene, name);
+ this._effects.set(document.id, document);
+ this._registerEffectNaturalIdle(document);
+ document.createParticle(document.root);
+ document.stop();
+ this._setNodePlaybackState(document.toNodeTree(), "stopped");
+ return document;
+ }
+}
diff --git a/editor/src/editor/windows/effect-editor/index.tsx b/editor/src/editor/windows/effect-editor/index.tsx
new file mode 100644
index 000000000..cc9d629a8
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/index.tsx
@@ -0,0 +1,151 @@
+import { ipcRenderer } from "electron";
+import { writeJSON } from "fs-extra";
+
+import { toast } from "sonner";
+
+import { Component, ReactNode } from "react";
+
+import { Toaster } from "../../../ui/shadcn/ui/sonner";
+
+import { EffectEditorLayout } from "./layout";
+import { EffectEditorToolbar } from "./toolbar";
+
+import { projectConfiguration, onProjectConfigurationChangedObservable, IProjectConfiguration } from "../../../project/configuration";
+import { EffectEditorAnimation } from "./animation";
+import { EffectEditorGraph } from "./graph";
+import { EffectEditorPreview } from "./preview";
+import { EffectEditorResources } from "./resources";
+
+export interface IEffectEditorWindowProps {
+ filePath?: string;
+ projectConfiguration?: IProjectConfiguration;
+}
+
+export interface IEffectEditorWindowState {
+ filePath: string | null;
+}
+
+export interface IEffectEditor {
+ layout: EffectEditorLayout | null;
+ preview: EffectEditorPreview | null;
+ graph: EffectEditorGraph | null;
+ animation: EffectEditorAnimation | null;
+ resources: EffectEditorResources | null;
+}
+export default class EffectEditorWindow extends Component {
+ public editor: IEffectEditor = {
+ layout: null,
+ preview: null,
+ graph: null,
+ animation: null,
+ resources: null,
+ };
+
+ public constructor(props: IEffectEditorWindowProps) {
+ super(props);
+
+ this.state = {
+ filePath: props.filePath || null,
+ };
+ }
+
+ public render(): ReactNode {
+ return (
+ <>
+
+
+
+
+ (this.editor.layout = r)} filePath={this.state.filePath || ""} editor={this.editor} />
+
+
+
+
+ >
+ );
+ }
+
+ public async componentDidMount(): Promise {
+ ipcRenderer.on("save", () => this.save());
+ ipcRenderer.on("editor:close-window", () => this.close());
+
+ // Set project configuration if provided
+ if (this.props.projectConfiguration) {
+ projectConfiguration.path = this.props.projectConfiguration.path;
+ projectConfiguration.compressedTexturesEnabled = this.props.projectConfiguration.compressedTexturesEnabled;
+ onProjectConfigurationChangedObservable.notifyObservers(projectConfiguration);
+ }
+
+ // Load file if filePath is provided (wait for graph to be ready)
+ if (this.props.filePath) {
+ // Wait a bit for graph component to mount
+ setTimeout(async () => {
+ if (this.editor.graph) {
+ await this.editor.graph.loadFromFile(this.props.filePath!);
+ }
+ }, 100);
+ }
+ }
+
+ public close(): void {
+ ipcRenderer.send("window:close");
+ }
+
+ public async loadFile(filePath: string): Promise {
+ this.setState({ filePath });
+ if (this.editor.graph) {
+ await this.editor.graph.loadFromFile(filePath);
+ }
+ }
+
+ public async save(filePath: string = this.state.filePath ?? ""): Promise {
+ if (!filePath || !this.editor.graph) {
+ return;
+ }
+
+ try {
+ const fileData = this.editor.graph.serializeToFileFormat();
+ await writeJSON(filePath, fileData, { spaces: "\t", encoding: "utf-8" });
+ if (filePath !== this.state.filePath) {
+ this.setState({ filePath });
+ }
+ toast.success("Effect saved");
+ ipcRenderer.send("editor:asset-updated", "Effect", fileData);
+ } catch (error) {
+ console.error("Failed to save Effect:", error);
+ toast.error("Failed to save Effect");
+ }
+ }
+
+ public async saveAs(filePath: string): Promise {
+ await this.save(filePath);
+ }
+
+ public async importFile(filePath: string): Promise {
+ try {
+ if (this.editor.graph) {
+ await this.editor.graph.loadFromFile(filePath);
+ toast.success("Effect imported");
+ } else {
+ toast.error("Failed to import Effect: Graph not available");
+ }
+ } catch (error) {
+ console.error("Failed to import Effect:", error);
+ toast.error("Failed to import Effect");
+ }
+ }
+
+ public async importQuarksFile(filePath: string): Promise {
+ try {
+ if (this.editor.graph) {
+ await this.editor.graph.loadFromQuarksFile(filePath);
+ toast.success("Quarks file imported");
+ } else {
+ toast.error("Failed to import Quarks file: Graph not available");
+ }
+ } catch (error) {
+ console.error("Failed to import Quarks file:", error);
+ toast.error("Failed to import Quarks file");
+ }
+ }
+}
diff --git a/editor/src/editor/windows/effect-editor/layout.tsx b/editor/src/editor/windows/effect-editor/layout.tsx
new file mode 100644
index 000000000..89504f070
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/layout.tsx
@@ -0,0 +1,310 @@
+import { Component, ReactNode } from "react";
+import { IJsonModel, Layout, Model, TabNode } from "flexlayout-react";
+
+import { waitNextAnimationFrame } from "../../../tools/tools";
+
+import { EffectEditorPreview } from "./preview";
+import { EffectEditorGraph } from "./graph";
+import { EffectEditorAnimation } from "./animation";
+import { EffectEditorPropertiesTab } from "./properties/tab";
+import { EffectEditorResources } from "./resources";
+import { IEffectEditor } from ".";
+
+const layoutModel: IJsonModel = {
+ global: {
+ tabSetEnableMaximize: true,
+ tabEnableRename: false,
+ tabSetMinHeight: 50,
+ tabSetMinWidth: 240,
+ enableEdgeDock: false,
+ },
+ layout: {
+ type: "row",
+ width: 100,
+ height: 100,
+ children: [
+ {
+ type: "row",
+ weight: 75,
+ children: [
+ {
+ type: "tabset",
+ weight: 75,
+ children: [
+ {
+ type: "tab",
+ id: "preview",
+ name: "Preview",
+ component: "preview",
+ enableClose: false,
+ enableRenderOnDemand: false,
+ },
+ ],
+ },
+ {
+ type: "tabset",
+ weight: 25,
+ children: [
+ {
+ type: "tab",
+ id: "animation",
+ name: "Animation",
+ component: "animation",
+ enableClose: false,
+ enableRenderOnDemand: false,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "row",
+ weight: 25,
+ children: [
+ {
+ type: "tabset",
+ weight: 40,
+ children: [
+ {
+ type: "tab",
+ id: "graph",
+ name: "Particles",
+ component: "graph",
+ enableClose: false,
+ enableRenderOnDemand: false,
+ },
+ {
+ type: "tab",
+ id: "resources",
+ name: "Resources",
+ component: "resources",
+ enableClose: false,
+ enableRenderOnDemand: false,
+ },
+ ],
+ },
+ {
+ type: "tabset",
+ weight: 60,
+ children: [
+ {
+ type: "tab",
+ id: "properties-object",
+ name: "Object",
+ component: "properties-object",
+ enableClose: false,
+ enableRenderOnDemand: false,
+ },
+ {
+ type: "tab",
+ id: "properties-emission",
+ name: "Emission",
+ component: "properties-emission",
+ enableClose: false,
+ enableRenderOnDemand: false,
+ },
+ {
+ type: "tab",
+ id: "properties-renderer",
+ name: "Renderer",
+ component: "properties-renderer",
+ enableClose: false,
+ enableRenderOnDemand: false,
+ },
+ {
+ type: "tab",
+ id: "properties-initialization",
+ name: "Initialization",
+ component: "properties-initialization",
+ enableClose: false,
+ enableRenderOnDemand: false,
+ },
+ {
+ type: "tab",
+ id: "properties-behaviors",
+ name: "Behaviors",
+ component: "properties-behaviors",
+ enableClose: false,
+ enableRenderOnDemand: false,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+};
+
+export interface IEffectEditorLayoutProps {
+ filePath: string | null;
+ editor: IEffectEditor;
+}
+
+export interface IEffectEditorLayoutState {
+ selectedNodeId: string | number | null;
+ resources: any[];
+ propertiesKey: number;
+}
+
+export class EffectEditorLayout extends Component {
+ private _model: Model = Model.fromJson(layoutModel as any);
+
+ private _components: Record = {};
+
+ public constructor(props: IEffectEditorLayoutProps) {
+ super(props);
+
+ this.state = {
+ selectedNodeId: null,
+ resources: [],
+ propertiesKey: 0,
+ };
+ }
+
+ public componentDidMount(): void {
+ this._updateComponents();
+ }
+
+ public componentDidUpdate(): void {
+ this._updateComponents();
+ }
+
+ private _handleNodeSelected = (nodeId: string | number | null): void => {
+ this.setState(
+ (prevState) => ({
+ selectedNodeId: nodeId,
+ propertiesKey: prevState.propertiesKey + 1, // Increment key to force component recreation
+ }),
+ () => {
+ // Update components immediately after state change
+ this._updateComponents();
+ // Force update layout to ensure flexlayout-react sees the new component
+ this.forceUpdate();
+ }
+ );
+ };
+
+ private _updateComponents(): void {
+ this._components = {
+ preview: (
+ (this.props.editor.preview = r!)}
+ filePath={this.props.filePath}
+ editor={this.props.editor}
+ selectedNodeId={this.state.selectedNodeId}
+ onSceneReady={() => {
+ // Update graph when scene is ready
+ if (this.props.editor.graph) {
+ this.props.editor.graph.forceUpdate();
+ }
+ }}
+ />
+ ),
+ graph: (
+ (this.props.editor.graph = r!)}
+ filePath={this.props.filePath}
+ onNodeSelected={this._handleNodeSelected}
+ editor={this.props.editor}
+ // onResourcesLoaded={(resources) => {
+ // this.setState({ resources });
+ // }}
+ />
+ ),
+ resources: (this.props.editor.resources = r!)} resources={this.state.resources} />,
+ animation: (this.props.editor.animation = r!)} filePath={this.props.filePath} editor={this.props.editor} />,
+ "properties-object": (
+ {
+ // Update graph node names when name changes
+ if (this.props.editor.graph) {
+ this.props.editor.graph.updateNodeNames();
+ }
+ }}
+ getNodeData={(nodeId) => this.props.editor.graph?.getNodeData(nodeId) || null}
+ />
+ ),
+ "properties-renderer": (
+ this.props.editor.graph?.getNodeData(nodeId) || null}
+ />
+ ),
+ "properties-emission": (
+ this.props.editor.graph?.getNodeData(nodeId) || null}
+ />
+ ),
+ "properties-initialization": (
+ this.props.editor.graph?.getNodeData(nodeId) || null}
+ />
+ ),
+ "properties-behaviors": (
+ this.props.editor.graph?.getNodeData(nodeId) || null}
+ />
+ ),
+ };
+ }
+
+ public render(): ReactNode {
+ return (
+
+ this._layoutFactory(n)} />
+
+ );
+ }
+
+ private _layoutFactory(node: TabNode): ReactNode {
+ const componentName = node.getComponent();
+ if (!componentName) {
+ return Error, see console...
;
+ }
+
+ // Always update components before returning, especially for properties tabs
+ // This ensures flexlayout-react gets the latest component with updated props
+ if (componentName.startsWith("properties-")) {
+ this._updateComponents();
+ }
+
+ const component = this._components[componentName];
+ if (!component) {
+ return Error, see console...
;
+ }
+
+ node.setEventListener("resize", () => {
+ waitNextAnimationFrame().then(() => this.props.editor.preview?.resize());
+ });
+
+ return (
+
+ {component}
+
+ );
+ }
+}
diff --git a/editor/src/editor/windows/effect-editor/preview-selection.ts b/editor/src/editor/windows/effect-editor/preview-selection.ts
new file mode 100644
index 000000000..cbebd077d
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/preview-selection.ts
@@ -0,0 +1,88 @@
+import type { Mesh } from "@babylonjs/core/Meshes/mesh";
+import type { Scene } from "@babylonjs/core/scene";
+import type { TransformNode } from "@babylonjs/core/Meshes/transformNode";
+import { Engine } from "@babylonjs/core/Engines/engine";
+import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial";
+import { Color3 } from "@babylonjs/core/Maths/math.color";
+import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder";
+import { PositionGizmo } from "@babylonjs/core/Gizmos/positionGizmo";
+import { UtilityLayerRenderer } from "@babylonjs/core/Rendering/utilityLayerRenderer";
+
+/**
+ * Draws a position gizmo and a thin torus on the selected Quarks group / emitter transform in the effect preview scene.
+ * All Babylon types must come from `@babylonjs/core` (same module as the preview Scene), not from the `babylonjs` bundle.
+ */
+export class EffectEditorPreviewSelection {
+ private readonly _scene: Scene;
+ private readonly _utilityLayer: UtilityLayerRenderer;
+ private readonly _positionGizmo: PositionGizmo;
+ private _ring: Mesh | null = null;
+
+ public constructor(scene: Scene) {
+ this._scene = scene;
+ this._utilityLayer = new UtilityLayerRenderer(scene);
+ this._utilityLayer.utilityLayerScene.postProcessesEnabled = false;
+
+ this._positionGizmo = new PositionGizmo(this._utilityLayer);
+ this._positionGizmo.scaleRatio = 2.5;
+ this._positionGizmo.planarGizmoEnabled = true;
+
+ this._softenPlanarMaterials();
+ }
+
+ /** Attaches gizmo and selection ring to the given transform, or clears both when null. */
+ public attachTo(node: TransformNode | null): void {
+ this._disposeRing();
+
+ this._positionGizmo.attachedNode = node;
+
+ if (!node) {
+ return;
+ }
+
+ const mat = new StandardMaterial("effectEditorSelectionRingMat", this._scene);
+ mat.disableLighting = true;
+ mat.emissiveColor = new Color3(0.35, 0.85, 1);
+ mat.alpha = 1;
+ mat.depthFunction = Engine.ALWAYS;
+ mat.disableDepthWrite = true;
+
+ const ring = MeshBuilder.CreateTorus("effectEditorSelectionRing", { diameter: 1.1, thickness: 0.06, tessellation: 48 }, this._scene);
+ ring.material = mat;
+ ring.parent = node;
+ ring.position.y = 0.12;
+ ring.isPickable = false;
+
+ this._ring = ring;
+ }
+
+ public dispose(): void {
+ this._disposeRing();
+ this._positionGizmo.attachedNode = null;
+ this._positionGizmo.dispose();
+ this._utilityLayer.dispose();
+ }
+
+ private _softenPlanarMaterials(): void {
+ try {
+ const pg = this._positionGizmo as unknown as {
+ xPlaneGizmo: { _coloredMaterial: { alpha: number }; _hoverMaterial: { alpha: number } };
+ yPlaneGizmo: { _coloredMaterial: { alpha: number }; _hoverMaterial: { alpha: number } };
+ zPlaneGizmo: { _coloredMaterial: { alpha: number }; _hoverMaterial: { alpha: number } };
+ };
+ pg.xPlaneGizmo._coloredMaterial.alpha = 0.35;
+ pg.xPlaneGizmo._hoverMaterial.alpha = 1;
+ pg.yPlaneGizmo._coloredMaterial.alpha = 0.35;
+ pg.yPlaneGizmo._hoverMaterial.alpha = 1;
+ pg.zPlaneGizmo._coloredMaterial.alpha = 0.35;
+ pg.zPlaneGizmo._hoverMaterial.alpha = 1;
+ } catch {
+ // Internal gizmo material layout may differ between Babylon versions.
+ }
+ }
+
+ private _disposeRing(): void {
+ this._ring?.dispose();
+ this._ring = null;
+ }
+}
diff --git a/editor/src/editor/windows/effect-editor/preview.tsx b/editor/src/editor/windows/effect-editor/preview.tsx
new file mode 100644
index 000000000..d61cb129e
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/preview.tsx
@@ -0,0 +1,290 @@
+import { Component, createRef, ReactNode } from "react";
+import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera";
+import { Engine } from "@babylonjs/core/Engines/engine";
+import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight";
+import { Color3, Color4 } from "@babylonjs/core/Maths/math.color";
+import { Vector3 } from "@babylonjs/core/Maths/math.vector";
+import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder";
+import { Scene } from "@babylonjs/core/scene";
+import { GridMaterial } from "@babylonjs/materials";
+import { ParticleSystem, QuarksUtil } from "babylon.quarks";
+import { IoPause, IoPlay, IoRefresh, IoStop } from "react-icons/io5";
+
+import { EditorInspectorNumberField } from "../../layout/inspector/fields/number";
+import { Button } from "../../../ui/shadcn/ui/button";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../../ui/shadcn/ui/tooltip";
+import type { IEffectEditor } from ".";
+import type { IPlaybackControlState } from "./graph";
+import { EffectEditorPreviewSelection } from "./preview-selection";
+
+// Required for Babylon particles support in scene runtime.
+import "@babylonjs/core/Particles/particleSystemComponent";
+import "@babylonjs/core/Shaders/particles.fragment";
+import "@babylonjs/core/Shaders/particles.vertex";
+import "@babylonjs/core/Shaders/rgbdDecode.fragment";
+
+export interface IEffectEditorPreviewProps {
+ filePath: string | null;
+ onSceneReady?: (scene: Scene) => void;
+ editor?: IEffectEditor;
+ selectedNodeId?: string | number | null;
+}
+
+export class EffectEditorPreview extends Component {
+ public engine: Engine | null = null;
+ public scene: Scene | null = null;
+ public camera: ArcRotateCamera | null = null;
+
+ private _lastFrameMs: number = performance.now();
+ private readonly _playSpeedModel = { playSpeed: 1 };
+ private _selectionVisual: EffectEditorPreviewSelection | null = null;
+ private readonly _perfParticleCountRef = createRef();
+ private readonly _perfFpsRef = createRef();
+
+ public componentDidUpdate(prevProps: IEffectEditorPreviewProps): void {
+ if (this._selectionVisual && (prevProps.selectedNodeId !== this.props.selectedNodeId || prevProps.editor?.graph !== this.props.editor?.graph)) {
+ this._syncSelectionVisual();
+ }
+ }
+
+ public componentWillUnmount(): void {
+ this._selectionVisual?.dispose();
+ this._selectionVisual = null;
+ this.scene?.dispose();
+ this.engine?.dispose();
+ }
+
+ public resize(): void {
+ this.engine?.resize();
+ }
+
+ public render(): ReactNode {
+ const showPlaybackBar = this._hasTreeSelection();
+ const controlState = showPlaybackBar ? this._getPlaybackControlState() : null;
+ const isPlaying = controlState?.state === "playing";
+ const playPauseTooltip = controlState?.reason ?? (isPlaying ? "Pause" : "Play");
+ const stopTooltip = controlState?.reason ?? "Stop";
+ const restartTooltip = controlState?.reason ?? "Restart";
+ return (
+
+
this._onGotCanvasRef(r)} className="w-full h-full outline-none" />
+
+
+ Particles: 0
+
+
+ FPS: 0
+
+
+ {showPlaybackBar && controlState !== null && controlState !== undefined && (
+
+
+ <>
+
+
+
+
+
+ this._handlePlayPause()}
+ className="h-10 w-10 shrink-0"
+ disabled={!controlState.canPlayPause}
+ >
+ {isPlaying ? : }
+
+
+ {playPauseTooltip}
+
+
+
+ this._handleStop()} className="h-10 w-10 shrink-0" disabled={!controlState.canStop}>
+
+
+
+ {stopTooltip}
+
+ {controlState.state !== "unavailable" && (
+
+
+ this._handleRestart()}
+ className="h-10 w-10 shrink-0"
+ disabled={!controlState.canRestart}
+ >
+
+
+
+ {restartTooltip}
+
+ )}
+ >
+
+
+ )}
+
+ );
+ }
+
+ /** True when the graph reports a real tree node for the current selection (hides preview bar when nothing is selected). */
+ private _hasTreeSelection(): boolean {
+ const id = this.props.selectedNodeId;
+ if (id === null || id === undefined || id === "") {
+ return false;
+ }
+ const nodeData = this.props.editor?.graph?.getNodeData(id);
+ return nodeData !== null && nodeData !== undefined;
+ }
+
+ /** Returns current playback state/availability for selected node. */
+ private _getPlaybackControlState(): IPlaybackControlState {
+ return (
+ this.props.editor?.graph?.getNodePlaybackControlState(this.props.selectedNodeId) ?? {
+ state: "unavailable",
+ canPlayPause: false,
+ canStop: false,
+ canRestart: false,
+ reason: "Preview is not ready.",
+ }
+ );
+ }
+
+ /** Initializes Babylon engine/scene and starts quarks update loop. */
+ private _onGotCanvasRef(canvas: HTMLCanvasElement | null): void {
+ if (!canvas || this.engine) {
+ return;
+ }
+
+ this.engine = new Engine(canvas, true, { antialias: true, adaptToDeviceRatio: true });
+ this.scene = new Scene(this.engine);
+ this.scene.clearColor = new Color4(0.1, 0.1, 0.1, 1.0);
+ this.scene.ambientColor = new Color3(1, 1, 1);
+
+ this.camera = new ArcRotateCamera("Camera", 0, 0.8, 4, Vector3.Zero(), this.scene);
+ this.camera.doNotSerialize = true;
+ this.camera.lowerRadiusLimit = 3;
+ this.camera.upperRadiusLimit = 10;
+ this.camera.wheelPrecision = 20;
+ this.camera.minZ = 0.001;
+ this.camera.attachControl(canvas, true);
+ this.camera.useFramingBehavior = true;
+ this.camera.wheelDeltaPercentage = 0.01;
+ this.camera.pinchDeltaPercentage = 0.01;
+ this.scene.activeCamera = this.camera;
+
+ const sunLight = new DirectionalLight("sun", new Vector3(-1, -1, -1), this.scene);
+ sunLight.intensity = 1.0;
+ sunLight.diffuse = new Color3(1, 1, 1);
+ sunLight.specular = new Color3(1, 1, 1);
+
+ const groundMaterial = new GridMaterial("groundMaterial", this.scene);
+ groundMaterial.majorUnitFrequency = 2;
+ groundMaterial.minorUnitVisibility = 0.1;
+ groundMaterial.gridRatio = 0.5;
+ groundMaterial.backFaceCulling = false;
+ groundMaterial.mainColor = new Color3(1, 1, 1);
+ groundMaterial.lineColor = new Color3(1.0, 1.0, 1.0);
+ groundMaterial.opacity = 0.5;
+
+ const ground = MeshBuilder.CreateGround("ground", { width: 100, height: 100 }, this.scene);
+ ground.material = groundMaterial;
+
+ this._selectionVisual = new EffectEditorPreviewSelection(this.scene);
+ this._syncSelectionVisual();
+
+ this.engine.runRenderLoop(() => {
+ const now = performance.now();
+ const deltaSeconds = Math.min((now - this._lastFrameMs) / 1000, 0.1);
+ this._lastFrameMs = now;
+ const scaledDelta = deltaSeconds * this._playSpeedModel.playSpeed;
+ for (const effect of this.props.editor?.graph?.getAllEffects() ?? []) {
+ effect.update(scaledDelta);
+ }
+ this._updatePerfHud();
+ this.scene?.render();
+ });
+
+ window.addEventListener("resize", () => this.engine?.resize());
+ this.props.onSceneReady?.(this.scene);
+ this.forceUpdate();
+ }
+
+ /** Writes particle total and engine FPS into the HUD (DOM refs; no React state per frame). */
+ private _updatePerfHud(): void {
+ if (!this.engine) {
+ return;
+ }
+ const fpsEl = this._perfFpsRef.current;
+ const particlesEl = this._perfParticleCountRef.current;
+ if (fpsEl) {
+ fpsEl.textContent = Math.round(this.engine.getFps()).toString();
+ }
+ if (particlesEl) {
+ let total = 0;
+ for (const effect of this.props.editor?.graph?.getAllEffects() ?? []) {
+ QuarksUtil.runOnAllParticleEmitters(effect.root, (emitter) => {
+ total += (emitter.system as ParticleSystem).particleNum;
+ });
+ }
+ particlesEl.textContent = total.toString();
+ }
+ }
+
+ /** Updates position gizmo + selection ring for the current graph selection. */
+ private _syncSelectionVisual(): void {
+ if (!this._selectionVisual) {
+ return;
+ }
+
+ const id = this.props.selectedNodeId;
+ if (id === null || id === undefined || id === "" || !this.props.editor?.graph?.getNodeData(id)) {
+ this._selectionVisual.attachTo(null);
+ return;
+ }
+
+ const transform = this.props.editor.graph.getNodeTransform(id);
+ this._selectionVisual.attachTo(transform);
+ }
+
+ /** Toggles selected node play/pause via graph bridge API. */
+ private _handlePlayPause(): void {
+ if (!this.props.selectedNodeId || !this.props.editor?.graph) {
+ return;
+ }
+
+ this.props.editor.graph.toggleNodePlayback(this.props.selectedNodeId);
+ }
+
+ /** Stops selected node playback via graph bridge API. */
+ private _handleStop(): void {
+ if (!this.props.selectedNodeId || !this.props.editor?.graph) {
+ return;
+ }
+
+ this.props.editor.graph.stopNode(this.props.selectedNodeId);
+ }
+
+ /** Restarts selected node playback via graph bridge API. */
+ private _handleRestart(): void {
+ if (!this.props.selectedNodeId || !this.props.editor?.graph) {
+ return;
+ }
+
+ this.props.editor.graph.restartNode(this.props.selectedNodeId);
+ }
+}
diff --git a/editor/src/editor/windows/effect-editor/properties/behaviors.tsx b/editor/src/editor/windows/effect-editor/properties/behaviors.tsx
new file mode 100644
index 000000000..1382b6def
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/properties/behaviors.tsx
@@ -0,0 +1,819 @@
+import { ReactNode } from "react";
+import { Color4 } from "@babylonjs/core/Maths/math.color";
+import { Vector3 } from "@babylonjs/core/Maths/math.vector";
+
+import { EditorInspectorNumberField } from "../../../layout/inspector/fields/number";
+import { EditorInspectorVectorField } from "../../../layout/inspector/fields/vector";
+import { EditorInspectorColorField } from "../../../layout/inspector/fields/color";
+import { EditorInspectorSwitchField } from "../../../layout/inspector/fields/switch";
+import { EditorInspectorStringField } from "../../../layout/inspector/fields/string";
+import { EditorInspectorListField } from "../../../layout/inspector/fields/list";
+import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block";
+import { EditorInspectorSectionField } from "../../../layout/inspector/fields/section";
+
+import { Button } from "../../../../ui/shadcn/ui/button";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../../../ui/shadcn/ui/dropdown-menu";
+import { HiOutlineTrash } from "react-icons/hi2";
+import { IoAddSharp } from "react-icons/io5";
+
+import {
+ ApplyForce,
+ ChangeEmitDirection,
+ ColorBySpeed,
+ ColorOverLife,
+ EmitSubParticleSystem,
+ ForceOverLife,
+ FrameOverLife,
+ GravityForce,
+ IntervalValue,
+ LimitSpeedOverLife,
+ Noise,
+ OrbitOverLife,
+ ParticleSystem as QuarksParticleSystem,
+ Rotation3DOverLife,
+ RotationBySpeed,
+ RotationOverLife,
+ SizeBySpeed,
+ SizeOverLife,
+ SpeedOverLife,
+ SubParticleEmitMode,
+ TurbulenceField,
+ WidthOverLength,
+ type Behavior as RuntimeBehavior,
+} from "babylon.quarks";
+import { BEHAVIOR_TYPES, type BehaviorKind, type Behavior } from "../types";
+import type { IQuarksNode } from "../quarks-bridge";
+import { EffectValueEditor } from "../editors/value";
+import { EffectColorEditor } from "../editors/color";
+import { EffectRotationEditor } from "../editors/rotation";
+import {
+ colorGeneratorToEditorColor,
+ editorColorToGenerator,
+ editorRotationToGenerator,
+ editorValueToFunctionGenerator,
+ editorValueToGenerator,
+ editorValueToVector3Generator,
+ generatorToEditorValue,
+ generatorToEditorVector3Value,
+ rotationGeneratorToEditorRotation,
+ toQuarksVector3,
+ type EditorColor,
+ type EditorRotation,
+ type EditorValue,
+ type EditorVector3Value,
+} from "../quarks-adapter";
+
+// Types
+export type FunctionType = "ConstantValue" | "IntervalValue" | "PiecewiseBezier" | "Vector3Function";
+export type ColorFunctionType = "ConstantColor" | "ColorRange" | "Gradient" | "RandomColor" | "RandomColorBetweenGradient";
+
+export interface IBehaviorProperty {
+ name: string;
+ type: "vector3" | "number" | "color" | "range" | "boolean" | "string" | "function" | "enum" | "colorFunction" | "rotation";
+ label: string;
+ default?: any;
+ enumItems?: Array<{ text: string; value: any }>;
+ functionTypes?: FunctionType[];
+ colorFunctionTypes?: ColorFunctionType[];
+}
+
+export interface IBehaviorDefinition {
+ type: string;
+ label: string;
+ kind?: BehaviorKind;
+ properties: IBehaviorProperty[];
+}
+
+/** Behavior config with optional editor-only id (for React keys). Runtime ignores id. */
+export type EditorBehavior = Behavior & { id?: string };
+const behaviorUiState = new WeakMap();
+
+function createBehaviorId(): string {
+ return `behavior-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+}
+
+function toIntervalValueGenerator(value: { min: number; max: number }): IntervalValue {
+ return new IntervalValue(value.min, value.max);
+}
+
+function createBehaviorInstance(config: EditorBehavior, system: QuarksParticleSystem): RuntimeBehavior | null {
+ switch (config.type) {
+ case BEHAVIOR_TYPES.ApplyForce:
+ return new ApplyForce(toQuarksVector3(config.direction, [0, 1, 0]), editorValueToGenerator(config.magnitude as EditorValue) as any);
+ case BEHAVIOR_TYPES.Noise:
+ return new Noise(
+ editorValueToGenerator(config.frequency as EditorValue),
+ editorValueToGenerator(config.power as EditorValue),
+ editorValueToGenerator(config.positionAmount as EditorValue),
+ editorValueToGenerator(config.rotationAmount as EditorValue)
+ );
+ case BEHAVIOR_TYPES.TurbulenceField:
+ return new TurbulenceField(
+ toQuarksVector3(config.scale, [2, 2, 2]),
+ Number(config.octaves ?? 1),
+ toQuarksVector3(config.velocityMultiplier, [1, 1, 1]),
+ toQuarksVector3(config.timeScale, [1, 1, 1])
+ );
+ case BEHAVIOR_TYPES.GravityForce:
+ return new GravityForce(toQuarksVector3(config.center), Number(config.magnitude ?? 0));
+ case BEHAVIOR_TYPES.ColorOverLife:
+ return new ColorOverLife(editorColorToGenerator(config.color as EditorColor) as any);
+ case BEHAVIOR_TYPES.RotationOverLife:
+ return new RotationOverLife(editorValueToGenerator(config.angularVelocity as EditorValue));
+ case BEHAVIOR_TYPES.Rotation3DOverLife:
+ return new Rotation3DOverLife(editorRotationToGenerator(config.angularVelocity as EditorRotation) as any);
+ case BEHAVIOR_TYPES.SizeOverLife:
+ return new SizeOverLife(editorValueToVector3Generator(config.size as EditorValue | EditorVector3Value) as any);
+ case BEHAVIOR_TYPES.ColorBySpeed:
+ return new ColorBySpeed(editorColorToGenerator(config.color as EditorColor) as any, toIntervalValueGenerator(config.speedRange));
+ case BEHAVIOR_TYPES.RotationBySpeed:
+ return new RotationBySpeed(editorValueToGenerator(config.angularVelocity as EditorValue), toIntervalValueGenerator(config.speedRange));
+ case BEHAVIOR_TYPES.SizeBySpeed:
+ return new SizeBySpeed(editorValueToVector3Generator(config.size as EditorValue | EditorVector3Value) as any, toIntervalValueGenerator(config.speedRange));
+ case BEHAVIOR_TYPES.SpeedOverLife:
+ return new SpeedOverLife(editorValueToFunctionGenerator(config.speed as EditorValue));
+ case BEHAVIOR_TYPES.FrameOverLife:
+ return new FrameOverLife(editorValueToFunctionGenerator(config.frame as EditorValue));
+ case BEHAVIOR_TYPES.ForceOverLife:
+ return new ForceOverLife(
+ editorValueToGenerator(config.x as EditorValue),
+ editorValueToGenerator(config.y as EditorValue),
+ editorValueToGenerator(config.z as EditorValue)
+ );
+ case BEHAVIOR_TYPES.OrbitOverLife:
+ return new OrbitOverLife(editorValueToGenerator(config.orbitSpeed as EditorValue), toQuarksVector3(config.axis, [0, 1, 0]));
+ case BEHAVIOR_TYPES.WidthOverLength:
+ return new WidthOverLength(editorValueToFunctionGenerator(config.width as EditorValue));
+ case BEHAVIOR_TYPES.ChangeEmitDirection:
+ return new ChangeEmitDirection(editorValueToGenerator(config.angle as EditorValue) as any);
+ case BEHAVIOR_TYPES.EmitSubParticleSystem:
+ return new EmitSubParticleSystem(system, !!config.useVelocityAsBasis, undefined, Number(config.mode ?? 0) as SubParticleEmitMode, Number(config.emitProbability ?? 1));
+ case BEHAVIOR_TYPES.LimitSpeedOverLife:
+ return new LimitSpeedOverLife(editorValueToFunctionGenerator(config.speed as EditorValue), Number(config.dampen ?? 0));
+ default:
+ return null;
+ }
+}
+
+function normalizeBehaviorForEditor(behavior: any): EditorBehavior {
+ const config: EditorBehavior = { ...(behavior?.toJSON?.() ?? behavior), id: createBehaviorId() };
+ const valueFields = [
+ "magnitude",
+ "frequency",
+ "power",
+ "positionAmount",
+ "rotationAmount",
+ "angularVelocity",
+ "size",
+ "speed",
+ "frame",
+ "x",
+ "y",
+ "z",
+ "orbitSpeed",
+ "width",
+ "angle",
+ ];
+ const colorFields = ["color"];
+
+ for (const field of valueFields) {
+ if (config[field]) {
+ if (config.type === BEHAVIOR_TYPES.Rotation3DOverLife && field === "angularVelocity") {
+ continue;
+ }
+ config[field] = field === "size" ? generatorToEditorVector3Value({ toJSON: () => config[field] }) : generatorToEditorValue({ toJSON: () => config[field] });
+ }
+ }
+ for (const field of colorFields) {
+ if (config[field]) {
+ config[field] = colorGeneratorToEditorColor({ toJSON: () => config[field] });
+ }
+ }
+ if (config.type === BEHAVIOR_TYPES.Rotation3DOverLife && config.angularVelocity) {
+ config.angularVelocity = rotationGeneratorToEditorRotation({ toJSON: () => config.angularVelocity });
+ }
+ return config;
+}
+
+function getEditorBehaviors(system: QuarksParticleSystem): EditorBehavior[] {
+ const cached = behaviorUiState.get(system);
+ if (cached) {
+ return cached;
+ }
+ const created = (system.behaviors ?? []).map(normalizeBehaviorForEditor);
+ behaviorUiState.set(system, created);
+ return created;
+}
+
+// Behavior Registry (keys from BEHAVIOR_TYPES; kind = system-level gradients vs per-particle)
+export const BehaviorRegistry: { [key: string]: IBehaviorDefinition } = {
+ [BEHAVIOR_TYPES.ApplyForce]: {
+ type: BEHAVIOR_TYPES.ApplyForce,
+ label: "Apply Force",
+ kind: "perParticle",
+ properties: [
+ { name: "direction", type: "vector3", label: "Direction", default: { x: 0, y: 1, z: 0 } },
+ {
+ name: "magnitude",
+ type: "function",
+ label: "Magnitude",
+ default: null,
+ functionTypes: ["ConstantValue", "IntervalValue"],
+ },
+ ],
+ },
+ [BEHAVIOR_TYPES.Noise]: {
+ type: BEHAVIOR_TYPES.Noise,
+ label: "Noise",
+ kind: "perParticle",
+ properties: [
+ {
+ name: "frequency",
+ type: "function",
+ label: "Frequency",
+ default: 1.0,
+ functionTypes: ["ConstantValue", "IntervalValue"],
+ },
+ {
+ name: "power",
+ type: "function",
+ label: "Power",
+ default: 1.0,
+ functionTypes: ["ConstantValue", "IntervalValue"],
+ },
+ {
+ name: "positionAmount",
+ type: "function",
+ label: "Position Amount",
+ default: 1.0,
+ functionTypes: ["ConstantValue", "IntervalValue"],
+ },
+ {
+ name: "rotationAmount",
+ type: "function",
+ label: "Rotation Amount",
+ default: 0.0,
+ functionTypes: ["ConstantValue", "IntervalValue"],
+ },
+ ],
+ },
+ [BEHAVIOR_TYPES.TurbulenceField]: {
+ type: BEHAVIOR_TYPES.TurbulenceField,
+ label: "Turbulence Field",
+ kind: "perParticle",
+ properties: [
+ { name: "scale", type: "vector3", label: "Scale", default: { x: 1, y: 1, z: 1 } },
+ { name: "octaves", type: "number", label: "Octaves", default: 1 },
+ { name: "velocityMultiplier", type: "vector3", label: "Velocity Multiplier", default: { x: 1, y: 1, z: 1 } },
+ { name: "timeScale", type: "vector3", label: "Time Scale", default: { x: 1, y: 1, z: 1 } },
+ ],
+ },
+ [BEHAVIOR_TYPES.GravityForce]: {
+ type: BEHAVIOR_TYPES.GravityForce,
+ label: "Gravity Force",
+ kind: "perParticle",
+ properties: [
+ { name: "center", type: "vector3", label: "Center", default: { x: 0, y: 0, z: 0 } },
+ { name: "magnitude", type: "number", label: "Magnitude", default: 1.0 },
+ ],
+ },
+ [BEHAVIOR_TYPES.ColorOverLife]: {
+ type: BEHAVIOR_TYPES.ColorOverLife,
+ label: "Color Over Life",
+ kind: "system",
+ properties: [
+ {
+ name: "color",
+ type: "colorFunction",
+ label: "Color",
+ default: null,
+ colorFunctionTypes: ["ConstantColor", "ColorRange", "Gradient", "RandomColorBetweenGradient"],
+ },
+ ],
+ },
+ [BEHAVIOR_TYPES.RotationOverLife]: {
+ type: BEHAVIOR_TYPES.RotationOverLife,
+ label: "Rotation Over Life",
+ kind: "system",
+ properties: [
+ {
+ name: "angularVelocity",
+ type: "rotation",
+ label: "Angular Velocity",
+ default: null,
+ functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"],
+ },
+ ],
+ },
+ [BEHAVIOR_TYPES.Rotation3DOverLife]: {
+ type: BEHAVIOR_TYPES.Rotation3DOverLife,
+ label: "Rotation 3D Over Life",
+ kind: "system",
+ properties: [
+ {
+ name: "angularVelocity",
+ type: "function",
+ label: "Angular Velocity",
+ default: null,
+ functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"],
+ },
+ ],
+ },
+ [BEHAVIOR_TYPES.SizeOverLife]: {
+ type: BEHAVIOR_TYPES.SizeOverLife,
+ label: "Size Over Life",
+ kind: "system",
+ properties: [
+ {
+ name: "size",
+ type: "function",
+ label: "Size",
+ default: null,
+ functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier", "Vector3Function"],
+ },
+ ],
+ },
+ [BEHAVIOR_TYPES.ColorBySpeed]: {
+ type: BEHAVIOR_TYPES.ColorBySpeed,
+ label: "Color By Speed",
+ kind: "perParticle",
+ properties: [
+ {
+ name: "color",
+ type: "colorFunction",
+ label: "Color",
+ default: null,
+ colorFunctionTypes: ["ConstantColor", "ColorRange", "Gradient", "RandomColorBetweenGradient"],
+ },
+ { name: "speedRange", type: "range", label: "Speed Range", default: { min: 0, max: 10 } },
+ ],
+ },
+ [BEHAVIOR_TYPES.RotationBySpeed]: {
+ type: BEHAVIOR_TYPES.RotationBySpeed,
+ label: "Rotation By Speed",
+ kind: "perParticle",
+ properties: [
+ {
+ name: "angularVelocity",
+ type: "function",
+ label: "Angular Velocity",
+ default: null,
+ functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"],
+ },
+ { name: "speedRange", type: "range", label: "Speed Range", default: { min: 0, max: 10 } },
+ ],
+ },
+ [BEHAVIOR_TYPES.SizeBySpeed]: {
+ type: BEHAVIOR_TYPES.SizeBySpeed,
+ label: "Size By Speed",
+ kind: "perParticle",
+ properties: [
+ {
+ name: "size",
+ type: "function",
+ label: "Size",
+ default: null,
+ functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier", "Vector3Function"],
+ },
+ { name: "speedRange", type: "range", label: "Speed Range", default: { min: 0, max: 10 } },
+ ],
+ },
+ [BEHAVIOR_TYPES.SpeedOverLife]: {
+ type: BEHAVIOR_TYPES.SpeedOverLife,
+ label: "Speed Over Life",
+ kind: "system",
+ properties: [
+ {
+ name: "speed",
+ type: "function",
+ label: "Speed",
+ default: null,
+ functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"],
+ },
+ ],
+ },
+ [BEHAVIOR_TYPES.FrameOverLife]: {
+ type: BEHAVIOR_TYPES.FrameOverLife,
+ label: "Frame Over Life",
+ kind: "system",
+ properties: [
+ {
+ name: "frame",
+ type: "function",
+ label: "Frame",
+ default: null,
+ functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"],
+ },
+ ],
+ },
+ [BEHAVIOR_TYPES.ForceOverLife]: {
+ type: BEHAVIOR_TYPES.ForceOverLife,
+ label: "Force Over Life",
+ kind: "perParticle",
+ properties: [
+ {
+ name: "x",
+ type: "function",
+ label: "X",
+ default: null,
+ functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"],
+ },
+ {
+ name: "y",
+ type: "function",
+ label: "Y",
+ default: null,
+ functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"],
+ },
+ {
+ name: "z",
+ type: "function",
+ label: "Z",
+ default: null,
+ functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"],
+ },
+ ],
+ },
+ [BEHAVIOR_TYPES.OrbitOverLife]: {
+ type: BEHAVIOR_TYPES.OrbitOverLife,
+ label: "Orbit Over Life",
+ kind: "perParticle",
+ properties: [
+ {
+ name: "orbitSpeed",
+ type: "function",
+ label: "Orbit Speed",
+ default: null,
+ functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"],
+ },
+ { name: "axis", type: "vector3", label: "Axis", default: { x: 0, y: 1, z: 0 } },
+ ],
+ },
+ [BEHAVIOR_TYPES.WidthOverLength]: {
+ type: BEHAVIOR_TYPES.WidthOverLength,
+ label: "Width Over Length",
+ kind: "perParticle",
+ properties: [
+ {
+ name: "width",
+ type: "function",
+ label: "Width",
+ default: null,
+ functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"],
+ },
+ ],
+ },
+ [BEHAVIOR_TYPES.ChangeEmitDirection]: {
+ type: BEHAVIOR_TYPES.ChangeEmitDirection,
+ label: "Change Emit Direction",
+ kind: "perParticle",
+ properties: [
+ {
+ name: "angle",
+ type: "function",
+ label: "Angle",
+ default: 0.0,
+ functionTypes: ["ConstantValue", "IntervalValue"],
+ },
+ ],
+ },
+ [BEHAVIOR_TYPES.EmitSubParticleSystem]: {
+ type: BEHAVIOR_TYPES.EmitSubParticleSystem,
+ label: "Emit Sub Particle System",
+ kind: "perParticle",
+ properties: [
+ { name: "subParticleSystem", type: "string", label: "Sub Particle System", default: "" },
+ { name: "useVelocityAsBasis", type: "boolean", label: "Use Velocity As Basis", default: false },
+ {
+ name: "mode",
+ type: "enum",
+ label: "Mode",
+ default: 0,
+ enumItems: [
+ { text: "Death", value: 0 },
+ { text: "Birth", value: 1 },
+ { text: "Frame", value: 2 },
+ ],
+ },
+ { name: "emitProbability", type: "number", label: "Emit Probability", default: 1.0 },
+ ],
+ },
+ [BEHAVIOR_TYPES.LimitSpeedOverLife]: {
+ type: BEHAVIOR_TYPES.LimitSpeedOverLife,
+ label: "Limit Speed Over Life",
+ kind: "system",
+ properties: [
+ {
+ name: "speed",
+ type: "function",
+ label: "Speed",
+ default: null,
+ functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"],
+ },
+ { name: "dampen", type: "number", label: "Dampen", default: 0.0 },
+ ],
+ },
+};
+
+// Utility functions
+export function getBehaviorDefinition(type: string): IBehaviorDefinition | undefined {
+ return BehaviorRegistry[type];
+}
+
+/** Creates a minimal behavior config for the given type; returned object may be extended with editor-only fields (e.g. id). */
+export function createDefaultBehaviorData(type: string): Behavior {
+ const definition = BehaviorRegistry[type];
+ if (!definition) {
+ return { type };
+ }
+
+ const data: Record = { type };
+ for (const prop of definition.properties) {
+ if (prop.type === "function") {
+ const fnType = prop.functionTypes?.[0] || "ConstantValue";
+ if (fnType === "ConstantValue") {
+ data[prop.name] = { type: "ConstantValue", value: prop.default !== undefined ? prop.default : 1.0 };
+ } else if (fnType === "IntervalValue") {
+ data[prop.name] = { type: "IntervalValue", a: 0, b: 1 };
+ } else {
+ data[prop.name] =
+ fnType === "Vector3Function"
+ ? {
+ type: "Vec3Function",
+ x: { type: "ConstantValue", value: 1 },
+ y: { type: "ConstantValue", value: 1 },
+ z: { type: "ConstantValue", value: 1 },
+ }
+ : {
+ type: "PiecewiseBezier",
+ functions: [{ function: { p0: 0, p1: 1 / 3, p2: (1 / 3) * 2, p3: 1 }, start: 0 }],
+ };
+ }
+ } else if (prop.type === "colorFunction") {
+ data[prop.name] = {
+ type: "ConstantColor",
+ color: [1, 1, 1, 1],
+ };
+ } else if (prop.type === "rotation") {
+ data[prop.name] = {
+ type: "Euler",
+ angleZ: { type: "ConstantValue", value: 0 },
+ order: "xyz",
+ };
+ } else if (prop.default !== undefined) {
+ if (prop.type === "vector3") {
+ data[prop.name] = { x: prop.default.x, y: prop.default.y, z: prop.default.z };
+ } else if (prop.type === "range") {
+ data[prop.name] = { min: prop.default.min, max: prop.default.max };
+ } else {
+ data[prop.name] = prop.default;
+ }
+ }
+ }
+ return data as Behavior;
+}
+
+// Helper function to render a single property (behavior may be mutated with Vector3/Color4 for inspector)
+function renderProperty(prop: IBehaviorProperty, behavior: Behavior, onChange: () => void): ReactNode {
+ switch (prop.type) {
+ case "vector3":
+ if (!behavior[prop.name]) {
+ const defaultVal = prop.default || { x: 0, y: 0, z: 0 };
+ behavior[prop.name] = new Vector3(defaultVal.x, defaultVal.y, defaultVal.z);
+ } else if (!(behavior[prop.name] instanceof Vector3)) {
+ const obj = behavior[prop.name];
+ behavior[prop.name] = new Vector3(obj.x || 0, obj.y || 0, obj.z || 0);
+ }
+ return ;
+
+ case "number":
+ if (behavior[prop.name] === undefined) {
+ behavior[prop.name] = prop.default !== undefined ? prop.default : 0;
+ }
+ return ;
+
+ case "color":
+ if (!behavior[prop.name]) {
+ behavior[prop.name] = prop.default ? new Color4(prop.default.r, prop.default.g, prop.default.b, prop.default.a) : new Color4(1, 1, 1, 1);
+ }
+ return ;
+
+ case "range":
+ if (!behavior[prop.name]) {
+ behavior[prop.name] = prop.default ? { ...prop.default } : { min: 0, max: 1 };
+ }
+ return (
+
+ {prop.label}
+
+
+
+
+
+ );
+
+ case "boolean":
+ if (behavior[prop.name] === undefined) {
+ behavior[prop.name] = prop.default !== undefined ? prop.default : false;
+ }
+ return ;
+
+ case "string":
+ if (behavior[prop.name] === undefined) {
+ behavior[prop.name] = prop.default !== undefined ? prop.default : "";
+ }
+ return ;
+
+ case "enum":
+ if (behavior[prop.name] === undefined) {
+ behavior[prop.name] = prop.default !== undefined ? prop.default : (prop.enumItems?.[0]?.value ?? 0);
+ }
+ if (!prop.enumItems || prop.enumItems.length === 0) {
+ return null;
+ }
+ return ;
+
+ case "colorFunction":
+ if (!behavior[prop.name]) {
+ behavior[prop.name] = {
+ type: "ConstantColor",
+ color: [1, 1, 1, 1],
+ };
+ }
+ return (
+ {
+ behavior[prop.name] = value;
+ onChange();
+ }}
+ label={prop.label}
+ />
+ );
+
+ case "rotation":
+ if (!behavior[prop.name]) {
+ behavior[prop.name] = {
+ type: "Euler",
+ angleZ: { type: "ConstantValue", value: 0 },
+ order: "xyz",
+ };
+ }
+ return (
+ {
+ behavior[prop.name] = value;
+ onChange();
+ }}
+ label={prop.label}
+ />
+ );
+
+ case "function":
+ if (!behavior[prop.name]) {
+ const functionType = prop.functionTypes?.[0] || "ConstantValue";
+ behavior[prop.name] =
+ functionType === "Vector3Function"
+ ? {
+ type: "Vec3Function",
+ x: { type: "ConstantValue", value: 1 },
+ y: { type: "ConstantValue", value: 1 },
+ z: { type: "ConstantValue", value: 1 },
+ }
+ : functionType === "IntervalValue"
+ ? { type: "IntervalValue", a: 0, b: 1 }
+ : functionType === "PiecewiseBezier"
+ ? { type: "PiecewiseBezier", functions: [{ function: { p0: 0, p1: 1 / 3, p2: (1 / 3) * 2, p3: 1 }, start: 0 }] }
+ : {
+ type: functionType,
+ value: prop.default !== undefined ? prop.default : 1,
+ };
+ }
+ return (
+ {
+ behavior[prop.name] = value;
+ onChange();
+ }}
+ availableTypes={prop.functionTypes?.map((type) => (type === "Vector3Function" ? "Vec3Function" : type))}
+ label={prop.label}
+ />
+ );
+
+ default:
+ return null;
+ }
+}
+
+// Component to render behavior properties
+interface IBehaviorPropertiesProps {
+ behavior: Behavior;
+ onChange: () => void;
+}
+
+function BehaviorProperties(props: IBehaviorPropertiesProps): ReactNode {
+ const { behavior, onChange } = props;
+ const definition = getBehaviorDefinition(behavior.type);
+
+ if (!definition) {
+ return null;
+ }
+
+ return <>{definition.properties.map((prop) => renderProperty(prop, behavior, onChange))}>;
+}
+
+// Main component
+export interface IEffectEditorBehaviorsPropertiesProps {
+ nodeData: IQuarksNode;
+ onChange: () => void;
+}
+
+export function EffectEditorBehaviorsProperties(props: IEffectEditorBehaviorsPropertiesProps): ReactNode {
+ const { nodeData, onChange } = props;
+
+ if (nodeData.type !== "particle" || !nodeData.data) {
+ return null;
+ }
+
+ const sourceSystem = nodeData.data;
+ if (!(sourceSystem instanceof QuarksParticleSystem)) {
+ return null;
+ }
+ const behaviorConfigs: EditorBehavior[] = getEditorBehaviors(sourceSystem);
+
+ const applyBehaviors = (): void => {
+ const runtimeBehaviors = behaviorConfigs.map((behavior) => createBehaviorInstance(behavior, sourceSystem)).filter((behavior): behavior is RuntimeBehavior => !!behavior);
+ sourceSystem.behaviors = runtimeBehaviors;
+ sourceSystem.neededToUpdateRender = true;
+ behaviorUiState.set(sourceSystem, behaviorConfigs);
+ onChange();
+ };
+
+ const handleAddBehavior = (behaviorType: string): void => {
+ const newBehavior: EditorBehavior = { ...createDefaultBehaviorData(behaviorType), id: createBehaviorId() };
+ behaviorConfigs.push(newBehavior);
+ applyBehaviors();
+ };
+
+ const handleRemoveBehavior = (index: number): void => {
+ behaviorConfigs.splice(index, 1);
+ applyBehaviors();
+ };
+
+ const handleBehaviorChange = (): void => {
+ applyBehaviors();
+ };
+
+ return (
+ <>
+ {behaviorConfigs.length === 0 && No behaviors. Click "Add Behavior" to add one.
}
+ {behaviorConfigs.map((behavior, index) => {
+ const definition = getBehaviorDefinition(behavior.type);
+ const title = definition?.label || behavior.type || `Behavior ${index + 1}`;
+
+ return (
+
+ {title}
+ {
+ e.stopPropagation();
+ handleRemoveBehavior(index);
+ }}
+ >
+
+
+
+ }
+ >
+
+
+ );
+ })}
+
+
+
+
+ Add Behavior
+
+
+
+ {Object.values(BehaviorRegistry).map((definition) => (
+ handleAddBehavior(definition.type)}>
+ {definition.label}
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/editor/src/editor/windows/effect-editor/properties/emission.tsx b/editor/src/editor/windows/effect-editor/properties/emission.tsx
new file mode 100644
index 000000000..7fbd62154
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/properties/emission.tsx
@@ -0,0 +1,490 @@
+import { ReactNode } from "react";
+
+import { EditorInspectorNumberField } from "../../../layout/inspector/fields/number";
+import { EditorInspectorSwitchField } from "../../../layout/inspector/fields/switch";
+import { EditorInspectorListField } from "../../../layout/inspector/fields/list";
+import { EditorInspectorSectionField } from "../../../layout/inspector/fields/section";
+
+import { type Value, parseConstantValue } from "../types";
+import { EffectValueEditor } from "../editors/value";
+import type { IQuarksNode } from "../quarks-bridge";
+import {
+ ConeEmitter,
+ ConstantValue,
+ EmitterMode,
+ HemisphereEmitter,
+ type ParticleSystem,
+ PointEmitter,
+ RectangleEmitter,
+ SphereEmitter,
+ type BurstParameters,
+} from "babylon.quarks";
+import { createConstantValue, editorValueToGenerator, generatorToEditorValue } from "../quarks-adapter";
+
+export interface IEffectEditorEmissionPropertiesProps {
+ nodeData: IQuarksNode;
+ onChange: () => void;
+}
+
+interface IEmissionBurst {
+ id: string;
+ time: Value;
+ count: Value;
+ cycle: number;
+ interval: number;
+ probability: number;
+}
+
+const burstEditorState = new WeakMap
();
+const createBurstId = (): string => `burst-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+
+function getEditableBursts(system: ParticleSystem): IEmissionBurst[] {
+ const cached = burstEditorState.get(system);
+ if (cached) {
+ return cached;
+ }
+ const created = (system.emissionBursts ?? []).map((burst) => ({
+ id: createBurstId(),
+ time: createConstantValue(Number(burst.time ?? 0)),
+ count: generatorToEditorValue(burst.count),
+ cycle: Number(burst.cycle ?? 1),
+ interval: Number(burst.interval ?? 0),
+ probability: Number(burst.probability ?? 1),
+ }));
+ burstEditorState.set(system, created);
+ return created;
+}
+
+function applyBursts(system: ParticleSystem, bursts: IEmissionBurst[]): void {
+ const runtimeBursts: BurstParameters[] = bursts.map((burst) => ({
+ time: parseConstantValue(burst.time, 0),
+ count: editorValueToGenerator(burst.count),
+ cycle: burst.cycle,
+ interval: burst.interval,
+ probability: burst.probability,
+ }));
+ system.emissionBursts = runtimeBursts;
+ system.neededToUpdateRender = true;
+ burstEditorState.set(system, bursts);
+}
+
+type ShapeKey = "point" | "sphere" | "cone" | "hemisphere" | "rectangle";
+
+function getShapeKey(system: ParticleSystem): ShapeKey {
+ const shape = system.emitterShape;
+ if (shape instanceof SphereEmitter) {
+ return "sphere";
+ }
+ if (shape instanceof ConeEmitter) {
+ return "cone";
+ }
+ if (shape instanceof HemisphereEmitter) {
+ return "hemisphere";
+ }
+ if (shape instanceof RectangleEmitter) {
+ return "rectangle";
+ }
+ return "point";
+}
+
+function setShape(system: ParticleSystem, shape: ShapeKey): void {
+ const current = system.emitterShape;
+ switch (shape) {
+ case "sphere":
+ system.emitterShape = new SphereEmitter({
+ radius: current instanceof SphereEmitter ? current.radius : 1,
+ arc: current instanceof SphereEmitter ? current.arc : Math.PI * 2,
+ thickness: current instanceof SphereEmitter ? current.thickness : 1,
+ mode: current instanceof SphereEmitter ? current.mode : EmitterMode.Random,
+ spread: current instanceof SphereEmitter ? current.spread : 0,
+ speed: current instanceof SphereEmitter ? current.speed : new ConstantValue(1),
+ });
+ break;
+ case "cone":
+ system.emitterShape = new ConeEmitter({
+ radius: current instanceof ConeEmitter ? current.radius : 1,
+ arc: current instanceof ConeEmitter ? current.arc : Math.PI * 2,
+ thickness: current instanceof ConeEmitter ? current.thickness : 1,
+ angle: current instanceof ConeEmitter ? current.angle : Math.PI / 6,
+ mode: current instanceof ConeEmitter ? current.mode : EmitterMode.Random,
+ spread: current instanceof ConeEmitter ? current.spread : 0,
+ speed: current instanceof ConeEmitter ? current.speed : new ConstantValue(1),
+ });
+ break;
+ case "hemisphere":
+ system.emitterShape = new HemisphereEmitter({
+ radius: current instanceof HemisphereEmitter ? current.radius : 1,
+ arc: current instanceof HemisphereEmitter ? current.arc : Math.PI * 2,
+ thickness: current instanceof HemisphereEmitter ? current.thickness : 1,
+ mode: current instanceof HemisphereEmitter ? current.mode : EmitterMode.Random,
+ spread: current instanceof HemisphereEmitter ? current.spread : 0,
+ speed: current instanceof HemisphereEmitter ? current.speed : new ConstantValue(1),
+ });
+ break;
+ case "rectangle":
+ system.emitterShape = new RectangleEmitter({
+ width: current instanceof RectangleEmitter ? current.width : 1,
+ height: current instanceof RectangleEmitter ? current.height : 1,
+ thickness: current instanceof RectangleEmitter ? current.thickness : 1,
+ mode: current instanceof RectangleEmitter ? current.mode : EmitterMode.Random,
+ spread: current instanceof RectangleEmitter ? current.spread : 0,
+ speed: current instanceof RectangleEmitter ? current.speed : new ConstantValue(1),
+ });
+ break;
+ case "point":
+ default:
+ system.emitterShape = new PointEmitter();
+ break;
+ }
+ system.neededToUpdateRender = true;
+}
+
+function renderEmitterShape(system: ParticleSystem, onChange: () => void): ReactNode {
+ const shape = system.emitterShape;
+ const shapeType = getShapeKey(system);
+
+ return (
+ <>
+ {
+ setShape(system, value as ShapeKey);
+ onChange();
+ }}
+ />
+
+ {shape instanceof SphereEmitter && (
+ <>
+
+
+
+
+
+ {
+ shape.speed = editorValueToGenerator(value as Value);
+ onChange();
+ }}
+ />
+ >
+ )}
+
+ {shape instanceof ConeEmitter && (
+ <>
+
+
+
+
+
+
+ {
+ shape.speed = editorValueToGenerator(value as Value);
+ onChange();
+ }}
+ />
+ >
+ )}
+
+ {shape instanceof HemisphereEmitter && (
+ <>
+
+
+
+
+
+ {
+ shape.speed = editorValueToGenerator(value as Value);
+ onChange();
+ }}
+ />
+ >
+ )}
+
+ {shape instanceof RectangleEmitter && (
+ <>
+
+
+
+
+
+ {
+ shape.speed = editorValueToGenerator(value as Value);
+ onChange();
+ }}
+ />
+ >
+ )}
+ >
+ );
+}
+
+/**
+ * Renders emission bursts
+ */
+function renderBursts(system: ParticleSystem, onChange: () => void): ReactNode {
+ const bursts = getEditableBursts(system);
+
+ const addBurst = () => {
+ const updated = [
+ ...bursts,
+ {
+ id: createBurstId(),
+ time: createConstantValue(0),
+ count: createConstantValue(1),
+ cycle: 1,
+ interval: 0,
+ probability: 1,
+ },
+ ];
+ applyBursts(system, updated);
+ onChange();
+ };
+
+ const removeBurst = (index: number) => {
+ const updated = bursts.filter((_, burstIndex) => burstIndex !== index);
+ applyBursts(system, updated);
+ onChange();
+ };
+
+ return (
+
+
+ {bursts.map((burst, idx) => (
+
+
+
Burst #{idx + 1}
+
removeBurst(idx)}>
+ Remove
+
+
+
+ {
+ const updated = [...bursts];
+ updated[idx] = { ...burst, time: val as Value };
+ applyBursts(system, updated);
+ onChange();
+ }}
+ />
+ {
+ const updated = [...bursts];
+ updated[idx] = { ...burst, count: val as Value };
+ applyBursts(system, updated);
+ onChange();
+ }}
+ />
+ {
+ const updated = [...bursts];
+ updated[idx] = { ...burst };
+ applyBursts(system, updated);
+ onChange();
+ }}
+ />
+ {
+ const updated = [...bursts];
+ updated[idx] = { ...burst };
+ applyBursts(system, updated);
+ onChange();
+ }}
+ />
+ {
+ const updated = [...bursts];
+ updated[idx] = { ...burst };
+ applyBursts(system, updated);
+ onChange();
+ }}
+ />
+
+
+ ))}
+
+ Add Burst
+
+
+
+ );
+}
+
+/**
+ * Renders emission parameters (looping, duration, emit over time/distance, bursts)
+ */
+function renderEmissionParameters(nodeData: IQuarksNode, onChange: () => void): ReactNode {
+ if (nodeData.type !== "particle" || !nodeData.data) {
+ return null;
+ }
+
+ const system = nodeData.data as ParticleSystem;
+ const durationProxy = {
+ get duration() {
+ return system.looping ? 0 : system.duration;
+ },
+ set duration(value: number) {
+ if (value === 0) {
+ system.looping = true;
+ return;
+ }
+ system.looping = false;
+ system.duration = Math.max(0.01, value);
+ },
+ };
+
+ const loopingProxy = {
+ get isLooping() {
+ return system.looping;
+ },
+ set isLooping(value: boolean) {
+ if (value) {
+ system.looping = true;
+ } else if (system.duration <= 0) {
+ system.duration = 5;
+ system.looping = false;
+ }
+ },
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {
+ system.emissionOverDistance = editorValueToGenerator(val as Value);
+ onChange();
+ }}
+ />
+
+
+ {renderBursts(system as any, onChange)}
+ >
+ );
+}
+
+/**
+ * Combined emission properties component
+ * Includes both emitter shape and emission parameters
+ */
+export function EffectEditorEmissionProperties(props: IEffectEditorEmissionPropertiesProps): ReactNode {
+ const { nodeData, onChange } = props;
+
+ if (nodeData.type !== "particle" || !nodeData.data) {
+ return null;
+ }
+ const system = nodeData.data as ParticleSystem;
+
+ return (
+ <>
+ {renderEmitterShape(system, onChange)}
+
+ {renderEmissionParameters(nodeData, onChange)}
+ >
+ );
+}
diff --git a/editor/src/editor/windows/effect-editor/properties/initialization.tsx b/editor/src/editor/windows/effect-editor/properties/initialization.tsx
new file mode 100644
index 000000000..848bc288e
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/properties/initialization.tsx
@@ -0,0 +1,133 @@
+import { ReactNode } from "react";
+
+import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block";
+import { type Color, type Rotation, type Value } from "../types";
+import type { IQuarksNode } from "../quarks-bridge";
+import { EffectValueEditor, type IVec3Function } from "../editors/value";
+import { EffectColorEditor } from "../editors/color";
+import { EffectRotationEditor } from "../editors/rotation";
+import { ParticleSystem as QuarksParticleSystem, Vector3Function } from "babylon.quarks";
+import {
+ colorGeneratorToEditorColor,
+ editorColorToGenerator,
+ editorRotationToGenerator,
+ editorValueToGenerator,
+ editorValueToVector3Generator,
+ generatorToEditorValue,
+ generatorToEditorVector3Value,
+ rotationGeneratorToEditorRotation,
+ type EditorVector3Value,
+} from "../quarks-adapter";
+
+export interface IEffectEditorParticleInitializationPropertiesProps {
+ nodeData: IQuarksNode;
+ onChange?: () => void;
+}
+
+function getVector3EditorValue(system: QuarksParticleSystem): EditorVector3Value {
+ const value = generatorToEditorVector3Value(system.startSize);
+ if (value.type === "Vec3Function") {
+ return value;
+ }
+ return { type: "Vec3Function", x: value, y: value, z: value };
+}
+
+export function EffectEditorParticleInitializationProperties(props: IEffectEditorParticleInitializationPropertiesProps): ReactNode {
+ const { nodeData } = props;
+ const onChange = props.onChange || (() => {});
+
+ if (nodeData.type !== "particle" || !nodeData.data) {
+ return null;
+ }
+
+ const system = nodeData.data as QuarksParticleSystem;
+
+ const getStartLife = (): Value => generatorToEditorValue(system.startLife);
+ const setStartLife = (value: Value): void => {
+ system.startLife = editorValueToGenerator(value);
+ onChange();
+ };
+
+ const getStartSize = (): Value | IVec3Function => {
+ return generatorToEditorVector3Value(system.startSize) as Value | IVec3Function;
+ };
+ const setStartSize = (value: Value | IVec3Function): void => {
+ if (typeof value === "object" && "type" in value && value.type === "Vec3Function") {
+ system.startSize = new Vector3Function(editorValueToGenerator(value.x), editorValueToGenerator(value.y), editorValueToGenerator(value.z));
+ } else {
+ system.startSize = editorValueToGenerator(value as Value);
+ }
+ onChange();
+ };
+
+ const getStartSpeed = (): Value => generatorToEditorValue(system.startSpeed);
+ const setStartSpeed = (value: Value): void => {
+ system.startSpeed = editorValueToGenerator(value);
+ onChange();
+ };
+
+ const getStartColor = (): Color | undefined => colorGeneratorToEditorColor(system.startColor);
+ const setStartColor = (value: Color): void => {
+ system.startColor = editorColorToGenerator(value);
+ onChange();
+ };
+
+ const getStartRotation = (): Rotation => rotationGeneratorToEditorRotation(system.startRotation);
+ const setStartRotation = (value: Rotation): void => {
+ system.startRotation = editorRotationToGenerator(value);
+ onChange();
+ };
+
+ const getScaleX = (): Value => getVector3EditorValue(system).x;
+ const setScaleX = (value: Value): void => {
+ const current = getVector3EditorValue(system);
+ system.startSize = editorValueToVector3Generator({ ...current, x: value });
+ onChange();
+ };
+
+ const getScaleY = (): Value => getVector3EditorValue(system).y;
+ const setScaleY = (value: Value): void => {
+ const current = getVector3EditorValue(system);
+ system.startSize = editorValueToVector3Generator({ ...current, y: value });
+ onChange();
+ };
+
+ return (
+ <>
+
+ Start Life
+
+
+
+ Start Size
+
+
+
+ Scale X
+
+
+
+ Scale Y
+
+
+
+ Start Speed
+
+
+
+ Start Color
+
+
+
+ Start Rotation
+
+
+ >
+ );
+}
diff --git a/editor/src/editor/windows/effect-editor/properties/object.tsx b/editor/src/editor/windows/effect-editor/properties/object.tsx
new file mode 100644
index 000000000..2a206078e
--- /dev/null
+++ b/editor/src/editor/windows/effect-editor/properties/object.tsx
@@ -0,0 +1,82 @@
+import { ReactNode } from "react";
+
+import { EditorInspectorStringField } from "../../../layout/inspector/fields/string";
+import { EditorInspectorVectorField } from "../../../layout/inspector/fields/vector";
+import { EditorInspectorSwitchField } from "../../../layout/inspector/fields/switch";
+
+import type { IQuarksNode } from "../quarks-bridge";
+import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh";
+import { TransformNode } from "@babylonjs/core/Meshes/transformNode";
+import { ParticleSystem as QuarksParticleSystem } from "babylon.quarks";
+
+export interface IEffectEditorObjectPropertiesProps {
+ nodeData: IQuarksNode;
+ onChange?: () => void;
+}
+
+export function EffectEditorObjectProperties(props: IEffectEditorObjectPropertiesProps): ReactNode {
+ const { nodeData, onChange } = props;
+
+ if (!nodeData.data) {
+ return (
+ <>
+
+ Data not available
+ >
+ );
+ }
+
+ const object = nodeData.data instanceof QuarksParticleSystem ? nodeData.data.emitter : nodeData.data;
+
+ const GetRotationInspector = (object: TransformNode | AbstractMesh, onFinishChange?: () => void): ReactNode => {
+ if (object.rotationQuaternion) {
+ const valueRef = object.rotationQuaternion.toEulerAngles();
+
+ const proxy = new Proxy(valueRef, {
+ get(target, prop) {
+ return target[prop];
+ },
+ set(obj, prop, value) {
+ obj[prop] = value;
+ object.rotationQuaternion?.copyFrom(obj.toQuaternion());
+
+ return true;
+ },
+ });
+
+ const o = { proxy };
+
+ return (
+ Rotation }
+ object={o}
+ property="proxy"
+ asDegrees
+ step={0.1}
+ onFinishChange={() => onFinishChange?.()}
+ />
+ );
+ }
+
+ return (
+