babylon.quarks-editor 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,13 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { Texture } from '@babylonjs/core/Materials/Textures/texture.js';
3
3
  import { Mesh } from '@babylonjs/core/Meshes/mesh.js';
4
4
  import { PlusIcon, XMarkIcon } from '@heroicons/react/24/solid';
5
- import { AxisAngleGenerator, BoxEmitFrom, ColorOverLife, ColorRange, ConstantColor, ConstantValue, EulerGenerator, Gradient, IntervalValue, RandomColor, RandomColorBetweenGradient, RandomQuatGenerator, RenderMode, SizeOverLife, Vector2, Vector3, Vector3Function, Vector4, normalizeTextureUV, } from 'babylon.quarks';
5
+ import { AxisAngleGenerator, BoxEmitFrom, ColorOverLife, ColorRange, ConeEmitFrom, ConstantColor, ConstantValue, EulerGenerator, Gradient, GridSampling, IntervalValue, RandomColor, RandomColorBetweenGradient, RandomQuatGenerator, RenderMode, SizeOverLife, Vector2, Vector3, Vector3Function, Vector4, normalizeTextureUV, } from 'babylon.quarks';
6
6
  import React, { useMemo } from 'react';
7
7
  import { DEFAULT_GRADIENT_STOPS, buildGradient, findBehavior, hexToRgb, readGradientStops, rgbToHex, } from '../core/colors.js';
8
8
  import { applyRendererMaterial, getMaterialLabel } from '../core/material.js';
9
9
  import { DEFAULT_SHAPE_PARAMS, SHAPE_PARAM_KEYS, SHAPE_TYPES, createShape, getShapeType, readShapeParams, } from '../core/shapes.js';
10
- import { buildCurve, buildScalar, readPieces, readScalar } from '../core/values.js';
11
- import { CurveEditor } from './CurveEditor.js';
10
+ import { buildCurve, buildScalar, readScalar } from '../core/values.js';
11
+ import { AxesCurveField } from './AxesCurveField.js';
12
12
  import { GradientEditor } from './GradientEditor.js';
13
13
  import { InspectorInfoIcon } from './InspectorInfoIcon.js';
14
14
  import { ModuleSection } from './ModuleSection.js';
@@ -60,6 +60,7 @@ const PARAM_LABELS = {
60
60
  column: 'Columns',
61
61
  row: 'Rows',
62
62
  depth: 'Depth',
63
+ length: 'Length',
63
64
  speed: 'Speed (lengths/s)',
64
65
  };
65
66
  /** Uppercase group title inside a module, as the Renderer's "Material" heading. */
@@ -143,14 +144,19 @@ export function MainModule({ binding }) {
143
144
  : startColor instanceof Gradient
144
145
  ? 'gradient'
145
146
  : colorMode;
146
- return (_jsxs(ModuleSection, { title: "Main", children: [_jsx(Row, { label: "Duration", children: _jsx(NumberField, { value: system.duration, min: 0.05, onChange: (v) => binding.apply((s) => (s.duration = v)) }) }), _jsx(Row, { label: "Looping", children: _jsx(CheckboxField, { value: system.looping, onChange: (v) => binding.apply((s) => (s.looping = v)) }) }), _jsx(Row, { label: "World space", children: _jsx(CheckboxField, { value: system.worldSpace, onChange: (v) => binding.apply((s) => (s.worldSpace = v)) }) }), system.worldSpace && (_jsx(Row, { label: "Scaling mode", children: _jsx(SelectField, { value: system.scalingMode, options: [
147
- { value: 'shape', label: 'Shape (sizes and shape only)' },
148
- { value: 'hierarchy', label: 'Hierarchy (motion scales too)' },
149
- ], onChange: (mode) => binding.apply((s) => (s.scalingMode = mode)) }) })), _jsx(Row, { label: "Prewarm", children: _jsx(CheckboxField, { value: system.prewarm, onChange: (v) => binding.apply((s) => (s.prewarm = v)) }) }), system.prewarm && (_jsx(Row, { label: "Warm-up time", children: _jsx(NumberField, { value: system.prewarmTime, step: 0.1, min: 0, onChange: (v) => binding.apply((s) => (s.prewarmTime = Math.max(0, v))) }) })), _jsx(Row, { label: "Playback speed", children: _jsx(NumberField, { value: system.playbackSpeed, step: 0.1, min: 0, onChange: (v) => binding.apply((s) => (s.playbackSpeed = Math.max(0, v))) }) }), _jsxs(Row, { label: "Fixed seed", children: [_jsx(CheckboxField, { value: system.seed !== undefined, onChange: (v) => binding.apply((s) => (s.seed = v ? (s.seed ?? 1) : undefined)) }), system.seed !== undefined && (_jsx(NumberField, { value: system.seed, step: 1, min: 0, onChange: (v) => binding.apply((s) => (s.seed = Math.max(0, Math.floor(v)))) }))] }), _jsx(Row, { label: "Off screen", children: _jsx(SelectField, { value: system.cullingMode, options: [
147
+ return (_jsxs(ModuleSection, { title: "Main", children: [_jsx(Row, { label: "Duration", children: _jsx(NumberField, { value: system.duration, min: 0.05, onChange: (v) => binding.apply((s) => (s.duration = v)) }) }), _jsx(Row, { label: "Looping", children: _jsx(CheckboxField, { value: system.looping, onChange: (v) => binding.apply((s) => (s.looping = v)) }) }), _jsx(Row, { label: "World space", children: _jsx(CheckboxField, { value: system.worldSpace, onChange: (v) => binding.apply((s) => (s.worldSpace = v)) }) }), _jsx(Row, { label: "Scaling mode", children: _jsx(SelectField, { value: system.scalingMode, options: [
148
+ { value: 'local', label: 'Local (own scale only)' },
149
+ { value: 'hierarchy', label: 'Hierarchy (parents too)' },
150
+ { value: 'shape', label: 'Shape (shape only)' },
151
+ ], onChange: (mode) => binding.apply((s) => (s.scalingMode = mode)) }) }), _jsx(Row, { label: "Prewarm", children: _jsx(CheckboxField, { value: system.prewarm, onChange: (v) => binding.apply((s) => (s.prewarm = v)) }) }), system.prewarm && (_jsx(Row, { label: "Warm-up time", children: _jsx(NumberField, { value: system.prewarmTime, step: 0.1, min: 0, onChange: (v) => binding.apply((s) => (s.prewarmTime = Math.max(0, v))) }) })), _jsx(Row, { label: "Playback speed", children: _jsx(NumberField, { value: system.playbackSpeed, step: 0.1, min: 0, onChange: (v) => binding.apply((s) => (s.playbackSpeed = Math.max(0, v))) }) }), _jsxs(Row, { label: "Fixed seed", children: [_jsx(CheckboxField, { value: system.seed !== undefined, onChange: (v) => binding.apply((s) => (s.seed = v ? (s.seed ?? 1) : undefined)) }), system.seed !== undefined && (_jsx(NumberField, { value: system.seed, step: 1, min: 0, onChange: (v) => binding.apply((s) => (s.seed = Math.max(0, Math.floor(v)))) }))] }), _jsx(Row, { label: "Off screen", children: _jsx(SelectField, { value: system.cullingMode, options: [
150
152
  { value: 'always', label: 'Always simulate' },
151
153
  { value: 'pause', label: 'Pause' },
152
154
  { value: 'pauseAndCatchUp', label: 'Pause and catch up' },
153
- ], onChange: (mode) => binding.apply((s) => (s.cullingMode = mode)) }) }), _jsx(ValueField, { label: "Start delay", generator: system.startDelay, min: 0, curveMax: 5, onChange: (g) => binding.apply((s) => {
155
+ ], onChange: (mode) => binding.apply((s) => (s.cullingMode = mode)) }) }), _jsx(Row, { label: "Stop action", hint: "What the system does once it has finished, as Unity's Stop Action. The editor saves it but does not apply it, so previews replay.", children: _jsx(SelectField, { value: system.stopAction, options: [
156
+ { value: 'none', label: 'None' },
157
+ { value: 'disable', label: 'Disable' },
158
+ { value: 'destroy', label: 'Destroy' },
159
+ ], onChange: (v) => binding.apply((s) => (s.stopAction = v)) }) }), _jsx(ValueField, { label: "Start delay", generator: system.startDelay, min: 0, curveMax: 5, onChange: (g) => binding.apply((s) => {
154
160
  s.startDelay = g;
155
161
  s.restart();
156
162
  }) }), _jsx(ValueField, { label: "Start lifetime", generator: system.startLife, min: 0.01, curveMax: 10, onChange: (g) => binding.apply((s) => (s.startLife = g)) }), _jsx(ValueField, { label: "Start speed", generator: system.startSpeed, min: 0, curveMax: 20, onChange: (g) => binding.apply((s) => (s.startSpeed = g)) }), _jsx(Row, { label: "Start size", children: _jsx(SelectField, { value: startSize3D ? '3d' : 'uniform', options: [
@@ -266,7 +272,10 @@ export function ShapeModule({ binding }) {
266
272
  const scene = system.emitter.getScene();
267
273
  // 'vfxBatch' is the renderer's own internal instancing mesh — never a valid emission source.
268
274
  const meshOptions = scene.meshes.filter((m) => m instanceof Mesh && m.name !== 'vfxBatch' && m.getTotalVertices() > 0);
269
- return (_jsxs(ModuleSection, { title: "Shape", children: [_jsx(Row, { label: "Shape", hintKey: "shape.type", children: _jsx(SelectField, { value: type, options: SHAPE_TYPES, onChange: (next) => binding.apply((s) => (s.emitterShape = createShape(next, params))) }) }), keys.map((key) => (_jsx(Row, { label: PARAM_LABELS[key], children: _jsx(NumberField, { value: params[key], min: 0, onChange: (v) => binding.apply((s) => (s.emitterShape = createShape(type, { ...params, [key]: v }))) }) }, key))), type === 'mesh_surface' && (_jsx(Row, { label: "Source mesh", children: _jsx(SelectField, { value: params.mesh?.uniqueId != null ? String(params.mesh.uniqueId) : '__none', options: [
275
+ return (_jsxs(ModuleSection, { title: "Shape", children: [_jsx(Row, { label: "Shape", hintKey: "shape.type", children: _jsx(SelectField, { value: type, options: SHAPE_TYPES, onChange: (next) => binding.apply((s) => (s.emitterShape = createShape(next, params))) }) }), keys
276
+ // A cone's Length only shapes Emit from Volume; Unity shows it for Cone Volume alone.
277
+ .filter((key) => key !== 'length' || params.coneEmitFrom === ConeEmitFrom.Volume)
278
+ .map((key) => (_jsx(Row, { label: PARAM_LABELS[key], children: _jsx(NumberField, { value: params[key], min: 0, onChange: (v) => binding.apply((s) => (s.emitterShape = createShape(type, { ...params, [key]: v }))) }) }, key))), type === 'mesh_surface' && (_jsx(Row, { label: "Source mesh", children: _jsx(SelectField, { value: params.mesh?.uniqueId != null ? String(params.mesh.uniqueId) : '__none', options: [
270
279
  { value: '__none', label: '(none)' },
271
280
  ...meshOptions.map((m) => ({ value: String(m.uniqueId), label: m.name })),
272
281
  ], onChange: (id) => {
@@ -276,7 +285,13 @@ export function ShapeModule({ binding }) {
276
285
  { value: BoxEmitFrom.Volume, label: 'Volume' },
277
286
  { value: BoxEmitFrom.Shell, label: 'Shell (faces)' },
278
287
  { value: BoxEmitFrom.Edge, label: 'Edges' },
279
- ], onChange: (emitFrom) => binding.apply((s) => (s.emitterShape = createShape(type, { ...params, emitFrom }))) }) })), !SHAPES_WITHOUT_MODE_SPREAD.has(type) && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Emit mode", children: _jsx(SelectField, { value: params.mode, options: [
288
+ ], onChange: (emitFrom) => binding.apply((s) => (s.emitterShape = createShape(type, { ...params, emitFrom }))) }) })), type === 'cone' && (_jsx(Row, { label: "Emit from", children: _jsx(SelectField, { value: params.coneEmitFrom, options: [
289
+ { value: ConeEmitFrom.Base, label: 'Base' },
290
+ { value: ConeEmitFrom.Volume, label: 'Volume' },
291
+ ], onChange: (coneEmitFrom) => binding.apply((s) => (s.emitterShape = createShape(type, { ...params, coneEmitFrom }))) }) })), type === 'grid' && (_jsx(Row, { label: "Sampling", children: _jsx(SelectField, { value: params.sampling, options: [
292
+ { value: GridSampling.Random, label: 'Random' },
293
+ { value: GridSampling.Sequential, label: 'Sequential' },
294
+ ], onChange: (sampling) => binding.apply((s) => (s.emitterShape = createShape(type, { ...params, sampling }))) }) })), !SHAPES_WITHOUT_MODE_SPREAD.has(type) && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Emit mode", children: _jsx(SelectField, { value: params.mode, options: [
280
295
  { value: 0, label: 'Random' },
281
296
  { value: 1, label: 'Loop' },
282
297
  { value: 2, label: 'Ping-pong' },
@@ -293,9 +308,9 @@ export function SizeOverLifeModule({ binding }) {
293
308
  if (enabled) {
294
309
  s.addBehavior(new SizeOverLife(buildScalar({ mode: 'curve', value: 1, min: 0, max: 1, curve })));
295
310
  }
296
- }), children: _jsx("div", { style: { marginTop: 6 }, children: _jsx(CurveEditor, { pieces: behavior ? readPieces(behavior.size) : [{ start: 0, p: curve }], maxValue: 2, onChange: (pieces) => binding.apply((s) => {
311
+ }), children: _jsx("div", { style: { marginTop: 6 }, children: _jsx(AxesCurveField, { generator: behavior ? behavior.size : buildCurve([{ start: 0, p: curve }]), maxValue: 2, onChange: (size) => binding.apply((s) => {
297
312
  removeBehavior(s, 'SizeOverLife');
298
- s.addBehavior(new SizeOverLife(buildCurve(pieces)));
313
+ s.addBehavior(new SizeOverLife(size));
299
314
  }) }) }) }));
300
315
  }
301
316
  /** The Color over Lifetime module (Unity's Color over Lifetime): a gradient over the particle's life. */
@@ -554,7 +569,17 @@ export function RendererModule({ binding, textureOptions, geometryOptions, resol
554
569
  { value: 'mix', label: 'Mix (outline)' },
555
570
  { value: 'fade', label: 'Fade (faces fade, edges stay)' },
556
571
  ], onChange: (mode) => patchRim({ mode }) }) }), _jsx(Row, { label: "Rim color", children: _jsx("div", { style: { display: 'flex', gap: 6 }, children: [0, 1, 2].map((channel) => (_jsx(NumberField, { value: rim.color[channel], min: 0, step: 0.05, onChange: (v) => setChannel(channel, v) }, channel))) }) }), _jsx(Row, { label: "Rim power", children: _jsx(NumberField, { value: rim.power, min: 0.01, step: 0.25, onChange: (power) => patchRim({ power }) }) }), _jsx(Row, { label: "Rim strength", children: _jsx(NumberField, { value: rim.strength, min: 0, step: 0.05, onChange: (strength) => patchRim({ strength }) }) })] }));
557
- })(), _jsx(Row, { label: "Render order", children: _jsx(NumberField, { value: system.renderOrder, step: 1, onChange: (v) => binding.apply((s) => (s.renderOrder = Math.round(v))) }) }), _jsx(Row, { label: "Rendering group", children: _jsx(NumberField, { value: system.renderingGroupId, step: 1, min: 0, onChange: (v) => binding.apply((s) => (s.renderingGroupId = Math.max(0, Math.round(v)))) }) }), _jsx(Row, { label: "Layer mask", children: _jsx(NumberField, { value: system.layers.mask, min: 0, step: 1, onChange: (v) => binding.apply((s) => (s.layers.mask = Math.max(0, Math.floor(v)))) }) }), _jsxs(Row, { label: "Max particles", children: [_jsx(CheckboxField, { value: system.maxParticles !== undefined, onChange: (v) => binding.apply((s) => (s.maxParticles = v ? 1000 : undefined)) }), system.maxParticles !== undefined && (_jsx(NumberField, { value: system.maxParticles, min: 1, step: 10, onChange: (v) => binding.apply((s) => (s.maxParticles = Math.max(1, Math.floor(v)))) }))] }), system.renderMode !== RenderMode.Mesh && system.renderMode !== RenderMode.Trail && (_jsx(Row, { label: "Pivot", children: _jsx("div", { style: { display: 'flex', gap: 6 }, children: ['x', 'y', 'z'].map((axis) => (_jsx(NumberField, { value: system.pivot?.[axis] ?? 0, step: 0.05, onChange: (v) => binding.apply((s) => {
572
+ })(), _jsx(Row, { label: "Render order", children: _jsx(NumberField, { value: system.renderOrder, step: 1, onChange: (v) => binding.apply((s) => (s.renderOrder = Math.round(v))) }) }), _jsx(Row, { label: "Rendering group", children: _jsx(NumberField, { value: system.renderingGroupId, step: 1, min: 0, onChange: (v) => binding.apply((s) => (s.renderingGroupId = Math.max(0, Math.round(v)))) }) }), _jsx(Row, { label: "Layer mask", children: _jsx(NumberField, { value: system.layers.mask, min: 0, step: 1, onChange: (v) => binding.apply((s) => (s.layers.mask = Math.max(0, Math.floor(v)))) }) }), _jsxs(Row, { label: "Max particles", children: [_jsx(CheckboxField, { value: system.maxParticles !== undefined, onChange: (v) => binding.apply((s) => (s.maxParticles = v ? 1000 : undefined)) }), system.maxParticles !== undefined && (_jsx(NumberField, { value: system.maxParticles, min: 1, step: 10, onChange: (v) => binding.apply((s) => (s.maxParticles = Math.max(1, Math.floor(v)))) }))] }), system.maxParticles !== undefined && system.renderMode !== RenderMode.Trail && (_jsx(Row, { label: "Ring buffer", hint: "What a full system does with new particles, as Unity's Ring Buffer Mode.", children: _jsx(SelectField, { value: system.ringBufferMode, options: [
573
+ { value: 'disabled', label: 'Disabled' },
574
+ { value: 'pauseUntilReplaced', label: 'Pause until replaced' },
575
+ { value: 'loopUntilReplaced', label: 'Loop until replaced' },
576
+ ], onChange: (v) => binding.apply((s) => (s.ringBufferMode = v)) }) })), system.maxParticles !== undefined &&
577
+ system.renderMode !== RenderMode.Trail &&
578
+ system.ringBufferMode === 'loopUntilReplaced' && (_jsx(Row, { label: "Loop range", hint: "The part of its life a particle plays again, from 0 to 1.", children: _jsx("div", { style: { display: 'flex', gap: 6 }, children: ['x', 'y'].map((end) => (_jsx(NumberField, { value: system.ringBufferLoopRange[end], min: 0, step: 0.05, onChange: (v) => binding.apply((s) => {
579
+ const next = s.ringBufferLoopRange.clone();
580
+ next[end] = Math.min(1, Math.max(0, v));
581
+ s.ringBufferLoopRange = new Vector2(Math.min(next.x, next.y), Math.max(next.x, next.y));
582
+ }) }, end))) }) })), system.renderMode !== RenderMode.Mesh && system.renderMode !== RenderMode.Trail && (_jsx(Row, { label: "Pivot", children: _jsx("div", { style: { display: 'flex', gap: 6 }, children: ['x', 'y', 'z'].map((axis) => (_jsx(NumberField, { value: system.pivot?.[axis] ?? 0, step: 0.05, onChange: (v) => binding.apply((s) => {
558
583
  const next = (s.pivot ?? new Vector3()).clone();
559
584
  next[axis] = v;
560
585
  s.pivot = next;
@@ -573,7 +598,7 @@ export function RendererModule({ binding, textureOptions, geometryOptions, resol
573
598
  ], onChange: (mode) => binding.apply((s) => (s.sortMode = mode)) }) }), _jsx(Row, { label: "Limit screen size", children: _jsx(CheckboxField, { value: system.minParticleSize > 0 || system.maxParticleSize !== Infinity, onChange: (v) => binding.apply((s) => {
574
599
  s.minParticleSize = 0;
575
600
  s.maxParticleSize = v ? 0.5 : Infinity;
576
- }) }) }), (system.minParticleSize > 0 || system.maxParticleSize !== Infinity) && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Min size (viewport)", children: _jsx(NumberField, { value: system.minParticleSize, step: 0.01, min: 0, onChange: (v) => binding.apply((s) => (s.minParticleSize = Math.max(0, v))) }) }), _jsx(Row, { label: "Max size (viewport)", children: _jsx(NumberField, { value: system.maxParticleSize === Infinity ? 0 : system.maxParticleSize, step: 0.05, min: 0, onChange: (v) => binding.apply((s) => (s.maxParticleSize = v > 0 ? v : Infinity)) }) })] })), _jsx(Row, { label: "Apply fog", children: _jsx(CheckboxField, { value: system.applyFog, onChange: (v) => binding.apply((s) => (s.applyFog = v)) }) }), _jsx(Row, { label: "Soft particles", children: _jsx(CheckboxField, { value: system.softParticles, onChange: (v) => binding.apply((s) => (s.softParticles = v)) }) }), system.softParticles && (_jsx(Row, { label: "Fade near/far", children: _jsxs("div", { style: { display: 'flex', gap: 6 }, children: [_jsx(NumberField, { value: system.softNearFade, min: 0, onChange: (v) => binding.apply((s) => (s.softNearFade = v)) }), _jsx(NumberField, { value: system.softFarFade, min: 0, onChange: (v) => binding.apply((s) => (s.softFarFade = v)) })] }) }))] }));
601
+ }) }) }), (system.minParticleSize > 0 || system.maxParticleSize !== Infinity) && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Min size (viewport)", children: _jsx(NumberField, { value: system.minParticleSize, step: 0.01, min: 0, onChange: (v) => binding.apply((s) => (s.minParticleSize = Math.max(0, v))) }) }), _jsx(Row, { label: "Max size (viewport)", children: _jsx(NumberField, { value: system.maxParticleSize === Infinity ? 0 : system.maxParticleSize, step: 0.05, min: 0, onChange: (v) => binding.apply((s) => (s.maxParticleSize = v > 0 ? v : Infinity)) }) })] })), _jsx(Row, { label: "Apply fog", children: _jsx(CheckboxField, { value: system.applyFog, onChange: (v) => binding.apply((s) => (s.applyFog = v)) }) }), _jsx(Row, { label: "Image processing", children: _jsx(CheckboxField, { value: system.applyImageProcessing, onChange: (v) => binding.apply((s) => (s.applyImageProcessing = v)) }) }), _jsx(Row, { label: "Soft particles", children: _jsx(CheckboxField, { value: system.softParticles, onChange: (v) => binding.apply((s) => (s.softParticles = v)) }) }), system.softParticles && (_jsx(Row, { label: "Fade near/far", children: _jsxs("div", { style: { display: 'flex', gap: 6 }, children: [_jsx(NumberField, { value: system.softNearFade, min: 0, onChange: (v) => binding.apply((s) => (s.softNearFade = v)) }), _jsx(NumberField, { value: system.softFarFade, min: 0, onChange: (v) => binding.apply((s) => (s.softFarFade = v)) })] }) }))] }));
577
602
  }
578
603
  function removeBehavior(system, type) {
579
604
  const behaviors = system.behaviors;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babylon.quarks-editor",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Shuriken-style effect editor for babylon.quarks: headless edit-model core + React module-stack UI",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -61,7 +61,7 @@
61
61
  "@babylonjs/materials": "^9.0.0",
62
62
  "@heroicons/react": ">=2.0.0",
63
63
  "@tanstack/react-virtual": ">=3.0.0",
64
- "babylon.quarks": "~0.28.0",
64
+ "babylon.quarks": "~0.29.0",
65
65
  "react": ">=18",
66
66
  "react-dom": ">=18"
67
67
  },
@@ -40,6 +40,8 @@ export function createDefaultEffect(
40
40
  transparent: true,
41
41
  blendMode: 1,
42
42
  });
43
+ // The editor previews over and over; the stop action is saved, not applied.
44
+ system.ignoreStopAction = true;
43
45
  system.addBehavior(new ColorOverLife(buildGradient(DEFAULT_GRADIENT_STOPS)));
44
46
  system.emitter.rotation.x = -Math.PI / 2;
45
47
  return system;
@@ -6,6 +6,7 @@ import {MeshBuilder} from '@babylonjs/core/Meshes/meshBuilder.js';
6
6
  import {TransformNode} from '@babylonjs/core/Meshes/transformNode.js';
7
7
  import type {Scene} from '@babylonjs/core/scene.js';
8
8
  import type {ParticleSystem} from 'babylon.quarks';
9
+ import {ConeEmitFrom} from 'babylon.quarks';
9
10
  import {EDITOR_SCENE_NODE_METADATA} from './editorScene.js';
10
11
  import './lineShaders.js';
11
12
  import {getShapeType, readShapeParams} from './shapes.js';
@@ -120,8 +121,16 @@ function buildEmitterWireframe(system: ParticleSystem, scene: Scene): Mesh[] {
120
121
  lines('wf-cone-spoke-b', [Vector3.Zero(), arcPoint(radius, arc)]);
121
122
  }
122
123
 
123
- const preview = Math.max(radius * 2, 1.25);
124
- const farRadius = radius + preview * Math.tan(angle);
124
+ // Emit from Volume draws the volume itself, out to its length; the base a stretch of the
125
+ // directions.
126
+ const preview =
127
+ params.coneEmitFrom === ConeEmitFrom.Volume
128
+ ? Math.max(Number.isFinite(params.length) ? params.length : 0, 0.001)
129
+ : Math.max(radius * 2, 1.25);
130
+ // The rays and the far rim end at the same point, which a near-flat cone keeps in view.
131
+ const travel = preview / Math.max(Math.cos(angle), 0.05);
132
+ const farRadius = radius + Math.sin(angle) * travel;
133
+ const farZ = Math.cos(angle) * travel;
125
134
  const spokeCount = Math.max(4, Math.ceil((arc / (Math.PI * 2)) * 12));
126
135
  for (let i = 0; i <= spokeCount; i++) {
127
136
  const theta = (i / spokeCount) * arc;
@@ -131,10 +140,9 @@ function buildEmitterWireframe(system: ParticleSystem, scene: Scene): Mesh[] {
131
140
  Math.sin(angle) * Math.sin(theta),
132
141
  Math.cos(angle)
133
142
  );
134
- const travel = preview / Math.max(Math.cos(angle), 0.05);
135
143
  lines(`wf-cone-ray-${i}`, [rim, rim.add(dir.scale(travel))], DIRECTION_COLOR);
136
144
  }
137
- lines('wf-cone-far', xyArc(farRadius, arc, 32, preview), DIRECTION_COLOR);
145
+ lines('wf-cone-far', xyArc(farRadius, arc, 32, farZ), DIRECTION_COLOR);
138
146
  break;
139
147
  }
140
148
  case 'sphere': {
@@ -110,6 +110,8 @@ export function parseEffectFromJson(
110
110
  const systems: ParticleSystem[] = [];
111
111
  QuarksUtil.runOnAllParticleEmitters(root, (emitter: ParticleEmitter) => {
112
112
  const system = emitter.system as ParticleSystem;
113
+ // The editor previews over and over; the stop action is saved, not applied.
114
+ system.ignoreStopAction = true;
113
115
  if (registerRenderer && renderer) {
114
116
  renderer.addSystem(system);
115
117
  }
@@ -4,12 +4,14 @@ import {
4
4
  BoxEmitFrom,
5
5
  BoxEmitter,
6
6
  CircleEmitter,
7
+ ConeEmitFrom,
7
8
  ConeEmitter,
8
9
  ConstantValue,
9
10
  DonutEmitter,
10
11
  EdgeEmitter,
11
12
  EmitterMode,
12
13
  GridEmitter,
14
+ GridSampling,
13
15
  HemisphereEmitter,
14
16
  MeshSurfaceEmitter,
15
17
  PointEmitter,
@@ -82,6 +84,12 @@ export interface ShapeParams {
82
84
  depth: number;
83
85
  /** Where a box emits from: its volume, its faces or its edges. */
84
86
  emitFrom: BoxEmitFrom;
87
+ /** Where a cone emits from: its base or its volume. */
88
+ coneEmitFrom: ConeEmitFrom;
89
+ /** Height of a cone's volume (Unity's Length), for Emit from Volume. */
90
+ length: number;
91
+ /** How a grid picks its cells: at random or in turn. */
92
+ sampling: GridSampling;
85
93
  /** How fast an edge emitter's loop / ping-pong point travels, in lengths per second. */
86
94
  speed: number;
87
95
  /** The mesh a mesh surface emitter emits from. */
@@ -103,6 +111,9 @@ export const DEFAULT_SHAPE_PARAMS: ShapeParams = {
103
111
  row: 10,
104
112
  depth: 1,
105
113
  emitFrom: BoxEmitFrom.Volume,
114
+ coneEmitFrom: ConeEmitFrom.Base,
115
+ length: 5,
116
+ sampling: GridSampling.Random,
106
117
  speed: 1,
107
118
  mesh: undefined,
108
119
  };
@@ -131,7 +142,11 @@ export function readShapeParams(shape: EmitterShape): ShapeParams {
131
142
  column: num('column', DEFAULT_SHAPE_PARAMS.column),
132
143
  row: num('row', DEFAULT_SHAPE_PARAMS.row),
133
144
  depth: num('depth', DEFAULT_SHAPE_PARAMS.depth),
134
- emitFrom: num('emitFrom', DEFAULT_SHAPE_PARAMS.emitFrom) as BoxEmitFrom,
145
+ // A box and a cone both name it emitFrom, with different enums: each keeps its own.
146
+ emitFrom: shape instanceof BoxEmitter ? shape.emitFrom : DEFAULT_SHAPE_PARAMS.emitFrom,
147
+ coneEmitFrom: shape instanceof ConeEmitter ? shape.emitFrom : DEFAULT_SHAPE_PARAMS.coneEmitFrom,
148
+ length: num('length', DEFAULT_SHAPE_PARAMS.length),
149
+ sampling: num('sampling', DEFAULT_SHAPE_PARAMS.sampling) as GridSampling,
135
150
  speed: anyShape.speed instanceof ConstantValue ? anyShape.speed.value : DEFAULT_SHAPE_PARAMS.speed,
136
151
  mesh: anyShape.mesh as Mesh | undefined,
137
152
  };
@@ -143,7 +158,7 @@ export const SHAPE_PARAM_KEYS: {[K in ShapeType]: Array<keyof ShapeParams>} = {
143
158
  point: [],
144
159
  sphere: ['radius', 'arc', 'thickness'],
145
160
  hemisphere: ['radius', 'arc', 'thickness'],
146
- cone: ['radius', 'arc', 'thickness', 'angle'],
161
+ cone: ['radius', 'arc', 'thickness', 'angle', 'length'],
147
162
  circle: ['radius', 'arc', 'thickness'],
148
163
  donut: ['radius', 'arc', 'thickness', 'donutRadius'],
149
164
  rectangle: ['width', 'height'],
@@ -180,6 +195,8 @@ export function createShape(type: ShapeType, params: ShapeParams): EmitterShape
180
195
  arc: params.arc,
181
196
  thickness: params.thickness,
182
197
  angle: params.angle,
198
+ emitFrom: params.coneEmitFrom,
199
+ length: params.length,
183
200
  mode: params.mode,
184
201
  spread: params.spread,
185
202
  });
@@ -213,6 +230,7 @@ export function createShape(type: ShapeType, params: ShapeParams): EmitterShape
213
230
  height: params.height,
214
231
  column: params.column,
215
232
  row: params.row,
233
+ sampling: params.sampling,
216
234
  });
217
235
  case 'box':
218
236
  return new BoxEmitter({
@@ -49,6 +49,8 @@ export function createChildSystem(parent: ParticleSystem, options: ChildSystemOp
49
49
  worldSpace: true,
50
50
  });
51
51
  child.emitter.name = options.name ?? (oneShot ? 'sub-emitter' : 'child-system');
52
+ // The editor previews over and over; the stop action is saved, not applied.
53
+ child.ignoreStopAction = true;
52
54
  if (oneShot) {
53
55
  child.onlyUsedByOther = true;
54
56
  }
@@ -1,12 +1,15 @@
1
1
  import type {FunctionValueGenerator, RotationGenerator, ValueGenerator, Vector3Generator} from 'babylon.quarks';
2
- import {Bezier, ConstantValue, IntervalValue, PiecewiseBezier} from 'babylon.quarks';
2
+ import {Bezier, ConstantValue, IntervalValue, PiecewiseBezier, RandomBetweenCurves} from 'babylon.quarks';
3
3
 
4
4
  /** A generator of one number: a constant or random value, or a curve over the particle's life. */
5
5
  export type ScalarGenerator = ValueGenerator | FunctionValueGenerator;
6
6
  /** Any generator a value field can be pointed at; ones that are not scalar read as the default. */
7
7
  export type AnyGenerator = ScalarGenerator | Vector3Generator | RotationGenerator;
8
- /** How a value field produces its number: Unity's Constant, Random Between Two Constants or Curve. */
9
- export type ScalarMode = 'constant' | 'random' | 'curve';
8
+ /**
9
+ * How a value field produces its number: Unity's Constant, Random Between Two Constants, Curve or
10
+ * Random Between Two Curves.
11
+ */
12
+ export type ScalarMode = 'constant' | 'random' | 'curve' | 'curves';
10
13
 
11
14
  /** Editable snapshot of a scalar value generator. */
12
15
  export interface ScalarValueState {
@@ -18,8 +21,10 @@ export interface ScalarValueState {
18
21
  min: number;
19
22
  /** Upper end of the random range (mode: random). */
20
23
  max: number;
21
- /** Cubic bezier control values over normalized lifetime (mode: curve). */
24
+ /** Cubic bezier control values over normalized lifetime (mode: curve; the first curve of curves). */
22
25
  curve: [number, number, number, number];
26
+ /** The second curve's control values (mode: curves); the first curve's when left out. */
27
+ curve2?: [number, number, number, number];
23
28
  }
24
29
 
25
30
  /**
@@ -27,7 +32,14 @@ export interface ScalarValueState {
27
32
  * Anything else, including a missing generator, reads as the constant 1.
28
33
  */
29
34
  export function readScalar(generator: AnyGenerator | undefined): ScalarValueState {
30
- const state: ScalarValueState = {mode: 'constant', value: 1, min: 0.5, max: 1.5, curve: [0, 1, 1, 0]};
35
+ const state: ScalarValueState = {
36
+ mode: 'constant',
37
+ value: 1,
38
+ min: 0.5,
39
+ max: 1.5,
40
+ curve: [0, 1, 1, 0],
41
+ curve2: [0, 1, 1, 0],
42
+ };
31
43
  if (generator instanceof ConstantValue) {
32
44
  state.mode = 'constant';
33
45
  state.value = generator.value;
@@ -40,15 +52,24 @@ export function readScalar(generator: AnyGenerator | undefined): ScalarValueStat
40
52
  state.value = (generator.a + generator.b) / 2;
41
53
  } else if (generator instanceof PiecewiseBezier) {
42
54
  state.mode = 'curve';
43
- const first = generator.functions[0]?.[0];
44
- if (first) {
45
- state.curve = [first.p[0], first.p[1], first.p[2], first.p[3]];
46
- state.value = first.p[0];
47
- }
55
+ state.curve = firstSegment(generator) ?? state.curve;
56
+ state.curve2 = state.curve;
57
+ state.value = state.curve[0];
58
+ } else if (generator instanceof RandomBetweenCurves) {
59
+ state.mode = 'curves';
60
+ state.curve = firstSegment(generator.curve1) ?? state.curve;
61
+ state.curve2 = firstSegment(generator.curve2) ?? state.curve;
62
+ state.value = state.curve[0];
48
63
  }
49
64
  return state;
50
65
  }
51
66
 
67
+ /** The control values of a curve's first segment, if it has one. */
68
+ function firstSegment(curve: PiecewiseBezier): [number, number, number, number] | undefined {
69
+ const first = curve.functions[0]?.[0];
70
+ return first ? [first.p[0], first.p[1], first.p[2], first.p[3]] : undefined;
71
+ }
72
+
52
73
  /** The generator a value field's state describes; a curve state builds a one-segment curve. */
53
74
  export function buildScalar(state: ScalarValueState & {mode: 'curve'}): PiecewiseBezier;
54
75
  export function buildScalar(state: ScalarValueState): ScalarGenerator;
@@ -60,6 +81,11 @@ export function buildScalar(state: ScalarValueState): ScalarGenerator {
60
81
  return new IntervalValue(Math.min(state.min, state.max), Math.max(state.min, state.max));
61
82
  case 'curve':
62
83
  return new PiecewiseBezier([[new Bezier(...state.curve), 0]]);
84
+ case 'curves':
85
+ return new RandomBetweenCurves(
86
+ new PiecewiseBezier([[new Bezier(...state.curve), 0]]),
87
+ new PiecewiseBezier([[new Bezier(...(state.curve2 ?? state.curve)), 0]])
88
+ );
63
89
  }
64
90
  }
65
91
 
@@ -71,8 +97,20 @@ export interface CurvePiece {
71
97
  p: [number, number, number, number];
72
98
  }
73
99
 
74
- /** Reads pieces from a PiecewiseBezier (or synthesizes one segment for other generators). */
75
- export function readPieces(generator: AnyGenerator | undefined): CurvePiece[] {
100
+ /**
101
+ * Reads pieces from a PiecewiseBezier, or from the first curve of a RandomBetweenCurves (the
102
+ * second with `second`). A constant reads as a flat curve, a random range as a flat curve at its
103
+ * lower end (its upper with `second`), and other generators as one default segment.
104
+ */
105
+ export function readPieces(generator: AnyGenerator | undefined, second = false): CurvePiece[] {
106
+ if (generator instanceof RandomBetweenCurves) {
107
+ return readPieces(second ? generator.curve2 : generator.curve1);
108
+ }
109
+ // A constant is a flat curve, and a random range two flat curves at its ends.
110
+ if (generator instanceof ConstantValue || generator instanceof IntervalValue) {
111
+ const value = generator instanceof ConstantValue ? generator.value : second ? generator.b : generator.a;
112
+ return [{start: 0, p: [value, value, value, value]}];
113
+ }
76
114
  if (generator instanceof PiecewiseBezier && generator.functions.length > 0) {
77
115
  return generator.functions.map(([bezier, start]) => ({
78
116
  start,
@@ -89,6 +127,29 @@ export function buildCurve(pieces: CurvePiece[]): PiecewiseBezier {
89
127
  return new PiecewiseBezier(sorted.map((piece) => [new Bezier(...piece.p), piece.start] as [Bezier, number]));
90
128
  }
91
129
 
130
+ /**
131
+ * `generator` as one curve (`'curve'`) or two (`'curves'`, Unity's Random Between Two Curves),
132
+ * keeping every segment: one curve becomes both bounds of two, and two keep their first as one.
133
+ * A constant is a flat curve, and a random range flat curves at its ends.
134
+ */
135
+ export function withCurves(generator: AnyGenerator | undefined, mode: 'curve'): PiecewiseBezier;
136
+ export function withCurves(generator: AnyGenerator | undefined, mode: 'curves'): RandomBetweenCurves;
137
+ export function withCurves(
138
+ generator: AnyGenerator | undefined,
139
+ mode: 'curve' | 'curves'
140
+ ): PiecewiseBezier | RandomBetweenCurves;
141
+ export function withCurves(
142
+ generator: AnyGenerator | undefined,
143
+ mode: 'curve' | 'curves'
144
+ ): PiecewiseBezier | RandomBetweenCurves {
145
+ const first = buildCurve(readPieces(generator));
146
+ if (mode === 'curve') {
147
+ return first;
148
+ }
149
+ const second = generator instanceof RandomBetweenCurves || generator instanceof IntervalValue;
150
+ return new RandomBetweenCurves(first, buildCurve(readPieces(generator, second)));
151
+ }
152
+
92
153
  /** Splits the piece containing x into two exact halves using Bezier.split. */
93
154
  export function splitPieces(pieces: CurvePiece[], x: number): CurvePiece[] {
94
155
  const sorted = [...pieces].sort((a, b) => a.start - b.start);
@@ -0,0 +1,72 @@
1
+ import {IntervalValue, PiecewiseBezier, RandomBetweenCurves, Vector3Function} from 'babylon.quarks';
2
+ import React from 'react';
3
+ import {AnyGenerator, withCurves} from '../core/values.js';
4
+ import {CheckboxField, Row} from './fields.js';
5
+ import {LifeCurveField} from './LifeCurveField.js';
6
+
7
+ const captionStyle: React.CSSProperties = {fontSize: 10.5, opacity: 0.6, marginTop: 6};
8
+
9
+ /** One curve or two, for one axis or for all of them. */
10
+ type AxisCurve = PiecewiseBezier | RandomBetweenCurves;
11
+
12
+ /** Props of an {@link AxesCurveField}. */
13
+ export interface AxesCurveFieldProps {
14
+ /** The generator shown: one curve (or two) for every axis, or a `Vector3Function` per axis. */
15
+ generator: AnyGenerator | undefined;
16
+ /** Top of the curve editors' value range. */
17
+ maxValue?: number;
18
+ /** Receives the edited generator. */
19
+ onChange: (generator: AxisCurve | Vector3Function) => void;
20
+ }
21
+
22
+ /**
23
+ * The curve or curves `generator` holds, as a fresh copy: a constant as a flat curve, a random
24
+ * range as two flat ones.
25
+ */
26
+ function curveOf(generator: AnyGenerator | undefined): AxisCurve {
27
+ const two = generator instanceof RandomBetweenCurves || generator instanceof IntervalValue;
28
+ return withCurves(generator, two ? 'curves' : 'curve');
29
+ }
30
+
31
+ /**
32
+ * A curve over life with Unity's Separate Axes switch: off, one curve (or two) for all axes; on, a
33
+ * {@link Vector3Function} with its own for X, Y and Z. Switching on gives every axis the shared
34
+ * curve; switching off keeps the X one.
35
+ */
36
+ export function AxesCurveField(props: AxesCurveFieldProps): React.ReactElement {
37
+ const {generator} = props;
38
+ const axes = generator instanceof Vector3Function ? generator : undefined;
39
+ const toggle = (on: boolean) =>
40
+ props.onChange(
41
+ on ? new Vector3Function(curveOf(generator), curveOf(generator), curveOf(generator)) : curveOf(axes?.x)
42
+ );
43
+ return (
44
+ <>
45
+ <Row label="Separate axes" hint="A curve for each of X, Y and Z, as Unity's Separate Axes.">
46
+ <CheckboxField value={axes !== undefined} onChange={toggle} />
47
+ </Row>
48
+ {axes === undefined ? (
49
+ <LifeCurveField generator={generator} maxValue={props.maxValue} onChange={props.onChange} />
50
+ ) : (
51
+ (['x', 'y', 'z'] as const).map((axis) => (
52
+ <div key={axis}>
53
+ <div style={captionStyle}>{axis.toUpperCase()}</div>
54
+ <LifeCurveField
55
+ generator={axes[axis]}
56
+ maxValue={props.maxValue}
57
+ onChange={(curve) =>
58
+ props.onChange(
59
+ new Vector3Function(
60
+ axis === 'x' ? curve : axes.x,
61
+ axis === 'y' ? curve : axes.y,
62
+ axis === 'z' ? curve : axes.z
63
+ )
64
+ )
65
+ }
66
+ />
67
+ </div>
68
+ ))
69
+ )}
70
+ </>
71
+ );
72
+ }
@@ -0,0 +1,57 @@
1
+ import {IntervalValue, PiecewiseBezier, RandomBetweenCurves} from 'babylon.quarks';
2
+ import React from 'react';
3
+ import {AnyGenerator, buildCurve, CurvePiece, readPieces, withCurves} from '../core/values.js';
4
+ import {CurveEditor} from './CurveEditor.js';
5
+ import {CheckboxField, Row} from './fields.js';
6
+
7
+ const captionStyle: React.CSSProperties = {fontSize: 10.5, opacity: 0.6, flex: 'none'};
8
+
9
+ /** Props of a {@link LifeCurveField}. */
10
+ export interface LifeCurveFieldProps {
11
+ /** The generator shown: one curve, two curves, or anything else, read as one curve. */
12
+ generator: AnyGenerator | undefined;
13
+ /** Top of the curve editors' value range. */
14
+ maxValue?: number;
15
+ /** Receives the edited curve or curves. */
16
+ onChange: (generator: PiecewiseBezier | RandomBetweenCurves) => void;
17
+ }
18
+
19
+ /**
20
+ * A setting that is a curve over the particle's life, or two with a "Two curves" switch (Unity's
21
+ * Random Between Two Curves): each particle then follows its own curve between the lower and the
22
+ * upper one. Every segment of both curves is kept through edits and switches.
23
+ */
24
+ export function LifeCurveField(props: LifeCurveFieldProps): React.ReactElement {
25
+ const {generator} = props;
26
+ // A random range shows as two flat curves, and an edit turns it into them.
27
+ const two = generator instanceof RandomBetweenCurves || generator instanceof IntervalValue;
28
+ const edit = (pieces: CurvePiece[], upper: boolean) => {
29
+ if (!two) {
30
+ props.onChange(buildCurve(pieces));
31
+ return;
32
+ }
33
+ const lower = upper ? buildCurve(readPieces(generator)) : buildCurve(pieces);
34
+ const higher = upper ? buildCurve(pieces) : buildCurve(readPieces(generator, true));
35
+ props.onChange(new RandomBetweenCurves(lower, higher));
36
+ };
37
+ return (
38
+ <>
39
+ <Row label="Two curves" hint="Random between two curves: each particle follows its own curve between them.">
40
+ <CheckboxField
41
+ value={two}
42
+ onChange={(on) => props.onChange(withCurves(generator, on ? 'curves' : 'curve'))}
43
+ />
44
+ </Row>
45
+ {(two ? (['lower', 'upper'] as const) : (['curve'] as const)).map((bound) => (
46
+ <div key={bound} style={{marginTop: 6}}>
47
+ {two && <span style={captionStyle}>{bound}</span>}
48
+ <CurveEditor
49
+ pieces={readPieces(generator, bound === 'upper')}
50
+ maxValue={props.maxValue}
51
+ onChange={(pieces) => edit(pieces, bound === 'upper')}
52
+ />
53
+ </div>
54
+ ))}
55
+ </>
56
+ );
57
+ }