babylon.quarks-editor 0.23.0 → 0.24.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.
- package/CHANGELOG.md +22 -0
- package/dist/core/material.d.ts +3 -0
- package/dist/core/material.js +12 -0
- package/dist/core/shapes.d.ts +8 -2
- package/dist/core/shapes.js +25 -1
- package/dist/react/EffectEditor.js +2 -2
- package/dist/react/EffectEditorHost.js +14 -2
- package/dist/react/SubEmittersModule.js +3 -1
- package/dist/react/ValueField.d.ts +2 -5
- package/dist/react/ValueField.js +2 -1
- package/dist/react/behaviorModules.d.ts +11 -0
- package/dist/react/behaviorModules.js +81 -3
- package/dist/react/modules.js +74 -23
- package/package.json +4 -3
- package/src/core/material.ts +15 -0
- package/src/core/shapes.ts +36 -0
- package/src/react/EffectEditor.tsx +4 -0
- package/src/react/EffectEditorHost.tsx +17 -2
- package/src/react/SubEmittersModule.tsx +15 -0
- package/src/react/ValueField.tsx +6 -1
- package/src/react/behaviorModules.tsx +315 -0
- package/src/react/modules.tsx +282 -28
package/dist/react/modules.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { Texture } from '@babylonjs/core/Materials/Textures/texture';
|
|
3
3
|
import { Mesh } from '@babylonjs/core/Meshes/mesh';
|
|
4
4
|
import { PlusIcon, XMarkIcon } from '@heroicons/react/24/solid';
|
|
5
|
-
import { ColorOverLife, ConstantColor, ConstantValue, EulerGenerator, Gradient, IntervalValue, RandomColor, RandomColorBetweenGradient, RandomQuatGenerator, RenderMode, SizeOverLife, Vector3Function, Vector4, } from 'babylon.quarks';
|
|
5
|
+
import { AxisAngleGenerator, BoxEmitFrom, ColorOverLife, ColorRange, ConstantColor, ConstantValue, EulerGenerator, Gradient, IntervalValue, RandomColor, RandomColorBetweenGradient, RandomQuatGenerator, RenderMode, SizeOverLife, Vector3, Vector3Function, Vector4, } from 'babylon.quarks';
|
|
6
6
|
import React, { useMemo, useState } from 'react';
|
|
7
7
|
import { DEFAULT_GRADIENT_STOPS, buildGradient, findBehavior, hexToRgb, readGradientStops, rgbToHex, } from '../core/colors';
|
|
8
8
|
import { applyRendererMaterial, getMaterialLabel } from '../core/material';
|
|
@@ -58,6 +58,8 @@ const PARAM_LABELS = {
|
|
|
58
58
|
height: 'Height',
|
|
59
59
|
column: 'Columns',
|
|
60
60
|
row: 'Rows',
|
|
61
|
+
depth: 'Depth',
|
|
62
|
+
speed: 'Speed (lengths/s)',
|
|
61
63
|
};
|
|
62
64
|
function ColorInput(props) {
|
|
63
65
|
const { value, onChange } = props;
|
|
@@ -76,23 +78,30 @@ function ColorInput(props) {
|
|
|
76
78
|
export function MainModule({ binding }) {
|
|
77
79
|
const system = binding.system;
|
|
78
80
|
const startColor = system.startColor;
|
|
79
|
-
const colorMode = startColor instanceof RandomColor ? 'random' : 'constant';
|
|
80
|
-
const
|
|
81
|
+
const colorMode = startColor instanceof RandomColor ? 'random' : startColor instanceof ColorRange ? 'range' : 'constant';
|
|
82
|
+
const twoColors = startColor instanceof RandomColor || startColor instanceof ColorRange;
|
|
83
|
+
const colorA = twoColors
|
|
81
84
|
? startColor.a
|
|
82
85
|
: startColor instanceof ConstantColor
|
|
83
86
|
? startColor.color
|
|
84
87
|
: new Vector4(1, 1, 1, 1);
|
|
85
|
-
const colorB =
|
|
88
|
+
const colorB = twoColors ? startColor.b : new Vector4(1, 0.5, 0.2, 1);
|
|
89
|
+
/** The two-colour generator of the current mode. */
|
|
90
|
+
const makeTwoColors = (a, b) => colorMode === 'range' ? new ColorRange(a, b) : new RandomColor(a, b);
|
|
86
91
|
const rotation = system.startRotation;
|
|
87
92
|
const isEuler = rotation instanceof EulerGenerator;
|
|
93
|
+
const isAxisAngle = rotation instanceof AxisAngleGenerator;
|
|
88
94
|
const rotationMode = rotation instanceof RandomQuatGenerator
|
|
89
95
|
? '3d'
|
|
90
96
|
: isEuler
|
|
91
97
|
? 'euler'
|
|
92
|
-
:
|
|
93
|
-
? '
|
|
94
|
-
: '
|
|
95
|
-
|
|
98
|
+
: isAxisAngle
|
|
99
|
+
? 'axisAngle'
|
|
100
|
+
: readScalar(rotation).mode === 'random'
|
|
101
|
+
? 'random'
|
|
102
|
+
: 'angle';
|
|
103
|
+
const rotationState = readScalar(rotation instanceof RandomQuatGenerator || isEuler || isAxisAngle ? undefined : rotation);
|
|
104
|
+
const axisAngle = isAxisAngle ? rotation : undefined;
|
|
96
105
|
const eulerAngles = {
|
|
97
106
|
x: isEuler ? readScalar(rotation.angleX).value : 0,
|
|
98
107
|
y: isEuler ? readScalar(rotation.angleY).value : 0,
|
|
@@ -109,7 +118,11 @@ export function MainModule({ binding }) {
|
|
|
109
118
|
: startColor instanceof Gradient
|
|
110
119
|
? 'gradient'
|
|
111
120
|
: colorMode;
|
|
112
|
-
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: "Prewarm", children: _jsx(CheckboxField, { value: system.prewarm, onChange: (v) => binding.apply((s) => (s.prewarm = v)) }) }), _jsx(
|
|
121
|
+
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: "Prewarm", children: _jsx(CheckboxField, { value: system.prewarm, onChange: (v) => binding.apply((s) => (s.prewarm = 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: [
|
|
122
|
+
{ value: 'always', label: 'Always simulate' },
|
|
123
|
+
{ value: 'pause', label: 'Pause' },
|
|
124
|
+
{ value: 'pauseAndCatchUp', label: 'Pause and catch up' },
|
|
125
|
+
], 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) => {
|
|
113
126
|
s.startDelay = g;
|
|
114
127
|
s.restart();
|
|
115
128
|
}) }), _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: size3D ? '3d' : 'uniform', options: [
|
|
@@ -131,12 +144,16 @@ export function MainModule({ binding }) {
|
|
|
131
144
|
}) }, axis))) })), _jsx(Row, { label: "Start color", children: _jsx(SelectField, { value: colorGenMode, options: [
|
|
132
145
|
{ value: 'constant', label: 'Constant' },
|
|
133
146
|
{ value: 'random', label: 'Random between two' },
|
|
147
|
+
{ value: 'range', label: 'Blend between two' },
|
|
134
148
|
{ value: 'gradient', label: 'Gradient' },
|
|
135
149
|
{ value: 'gradient2', label: 'Random between gradients' },
|
|
136
150
|
], onChange: (mode) => binding.apply((s) => {
|
|
137
151
|
if (mode === 'random') {
|
|
138
152
|
s.startColor = new RandomColor(colorA, colorB);
|
|
139
153
|
}
|
|
154
|
+
else if (mode === 'range') {
|
|
155
|
+
s.startColor = new ColorRange(colorA, colorB);
|
|
156
|
+
}
|
|
140
157
|
else if (mode === 'gradient') {
|
|
141
158
|
s.startColor = buildGradient(DEFAULT_GRADIENT_STOPS);
|
|
142
159
|
}
|
|
@@ -146,10 +163,9 @@ export function MainModule({ binding }) {
|
|
|
146
163
|
else {
|
|
147
164
|
s.startColor = new ConstantColor(colorA);
|
|
148
165
|
}
|
|
149
|
-
}) }) }), (colorGenMode === 'constant' ||
|
|
150
|
-
s.startColor =
|
|
151
|
-
|
|
152
|
-
}) }) })), colorGenMode === 'random' && (_jsx(Row, { label: "", children: _jsx(ColorInput, { value: colorB, onChange: (next) => binding.apply((s) => (s.startColor = new RandomColor(colorA, next))) }) })), colorGenMode === 'gradient' && (_jsx("div", { style: { marginTop: 6 }, children: _jsx(GradientEditor, { stops: readGradientStops(startColor), onChange: (stops) => binding.apply((s) => (s.startColor = buildGradient(stops))) }) })), colorGenMode === 'gradient2' && (_jsxs("div", { style: { marginTop: 6, display: 'flex', flexDirection: 'column', gap: 6 }, children: [_jsx(GradientEditor, { stops: readGradientStops(startColor.gradient1), onChange: (stops) => binding.apply((s) => {
|
|
166
|
+
}) }) }), (colorGenMode === 'constant' || twoColors) && (_jsx(Row, { label: "", children: _jsx(ColorInput, { value: colorA, onChange: (next) => binding.apply((s) => {
|
|
167
|
+
s.startColor = twoColors ? makeTwoColors(next, colorB) : new ConstantColor(next);
|
|
168
|
+
}) }) })), twoColors && (_jsx(Row, { label: "", children: _jsx(ColorInput, { value: colorB, onChange: (next) => binding.apply((s) => (s.startColor = makeTwoColors(colorA, next))) }) })), colorGenMode === 'gradient' && (_jsx("div", { style: { marginTop: 6 }, children: _jsx(GradientEditor, { stops: readGradientStops(startColor), onChange: (stops) => binding.apply((s) => (s.startColor = buildGradient(stops))) }) })), colorGenMode === 'gradient2' && (_jsxs("div", { style: { marginTop: 6, display: 'flex', flexDirection: 'column', gap: 6 }, children: [_jsx(GradientEditor, { stops: readGradientStops(startColor.gradient1), onChange: (stops) => binding.apply((s) => {
|
|
153
169
|
const g = s.startColor;
|
|
154
170
|
s.startColor = new RandomColorBetweenGradient(buildGradient(stops), g.gradient2);
|
|
155
171
|
}) }), _jsx(GradientEditor, { stops: readGradientStops(startColor.gradient2), onChange: (stops) => binding.apply((s) => {
|
|
@@ -159,17 +175,24 @@ export function MainModule({ binding }) {
|
|
|
159
175
|
{ value: 'angle', label: 'Angle (rad)' },
|
|
160
176
|
{ value: 'random', label: 'Random angle' },
|
|
161
177
|
{ value: 'euler', label: '3D (per axis)' },
|
|
178
|
+
{ value: 'axisAngle', label: '3D (axis + angle)' },
|
|
162
179
|
{ value: '3d', label: 'Random 3D' },
|
|
163
180
|
], onChange: (mode) => binding.apply((s) => {
|
|
164
181
|
s.startRotation =
|
|
165
182
|
mode === '3d'
|
|
166
183
|
? new RandomQuatGenerator()
|
|
167
|
-
: mode === '
|
|
168
|
-
? new
|
|
169
|
-
: mode === '
|
|
170
|
-
? new
|
|
171
|
-
:
|
|
172
|
-
|
|
184
|
+
: mode === 'axisAngle'
|
|
185
|
+
? new AxisAngleGenerator(new Vector3(0, 1, 0), new ConstantValue(rotationState.value))
|
|
186
|
+
: mode === 'euler'
|
|
187
|
+
? new EulerGenerator(new ConstantValue(eulerAngles.x), new ConstantValue(eulerAngles.y), new ConstantValue(eulerAngles.z))
|
|
188
|
+
: mode === 'random'
|
|
189
|
+
? new IntervalValue(0, Math.PI * 2)
|
|
190
|
+
: new ConstantValue(rotationState.value);
|
|
191
|
+
}) }) }), rotationMode === 'euler' && (_jsx(_Fragment, { children: ['x', 'y', 'z'].map((axis) => (_jsx(Row, { label: ` ${axis.toUpperCase()} (rad)`, children: _jsx(NumberField, { value: eulerAngles[axis], step: 0.1, onChange: (v) => setEulerAxis(axis, v) }) }, axis))) })), axisAngle && (_jsxs(_Fragment, { children: [_jsx(Row, { label: " Axis", children: _jsx("div", { style: { display: 'flex', gap: 6 }, children: ['x', 'y', 'z'].map((axis) => (_jsx(NumberField, { value: axisAngle.axis[axis], step: 0.1, onChange: (v) => binding.apply((s) => {
|
|
192
|
+
const next = axisAngle.axis.clone();
|
|
193
|
+
next[axis] = v;
|
|
194
|
+
s.startRotation = new AxisAngleGenerator(next, axisAngle.angle);
|
|
195
|
+
}) }, axis))) }) }), _jsx(ValueField, { label: " Angle (rad)", generator: axisAngle.angle, curveMax: Math.PI * 2, onChange: (g) => binding.apply((s) => (s.startRotation = new AxisAngleGenerator(axisAngle.axis.clone(), g))) })] })), rotationMode === 'angle' && (_jsx(Row, { label: "", children: _jsx(NumberField, { value: rotationState.value, step: 0.1, onChange: (v) => binding.apply((s) => (s.startRotation = new ConstantValue(v))) }) })), rotationMode === 'random' && (_jsx(Row, { label: "", children: _jsxs("div", { style: { display: 'flex', gap: 6 }, children: [_jsx(NumberField, { value: rotationState.min, step: 0.1, onChange: (v) => binding.apply((s) => (s.startRotation = new IntervalValue(v, rotationState.max))) }), _jsx(NumberField, { value: rotationState.max, step: 0.1, onChange: (v) => binding.apply((s) => (s.startRotation = new IntervalValue(rotationState.min, v))) })] }) }))] }));
|
|
173
196
|
}
|
|
174
197
|
export function EmissionModule({ binding }) {
|
|
175
198
|
const system = binding.system;
|
|
@@ -204,7 +227,7 @@ export function EmissionModule({ binding }) {
|
|
|
204
227
|
// Shapes whose constructor doesn't accept `mode`/`spread` at all — showing those controls
|
|
205
228
|
// for them would edit a value the shape silently ignores, just like the old "default to
|
|
206
229
|
// first texture" bug: a control that looks live but does nothing.
|
|
207
|
-
const SHAPES_WITHOUT_MODE_SPREAD = new Set(['point', 'grid', 'mesh_surface']);
|
|
230
|
+
const SHAPES_WITHOUT_MODE_SPREAD = new Set(['point', 'grid', 'box', 'mesh_surface']);
|
|
208
231
|
export function ShapeModule({ binding }) {
|
|
209
232
|
const system = binding.system;
|
|
210
233
|
const type = getShapeType(system.emitterShape);
|
|
@@ -219,12 +242,16 @@ export function ShapeModule({ binding }) {
|
|
|
219
242
|
], onChange: (id) => {
|
|
220
243
|
const mesh = id === '__none' ? undefined : meshOptions.find((m) => String(m.uniqueId) === id);
|
|
221
244
|
binding.apply((s) => (s.emitterShape = createShape('mesh_surface', { ...params, mesh })));
|
|
222
|
-
} }) })),
|
|
245
|
+
} }) })), type === 'box' && (_jsx(Row, { label: "Emit from", children: _jsx(SelectField, { value: params.emitFrom, options: [
|
|
246
|
+
{ value: BoxEmitFrom.Volume, label: 'Volume' },
|
|
247
|
+
{ value: BoxEmitFrom.Shell, label: 'Shell (faces)' },
|
|
248
|
+
{ value: BoxEmitFrom.Edge, label: 'Edges' },
|
|
249
|
+
], 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: [
|
|
223
250
|
{ value: 0, label: 'Random' },
|
|
224
251
|
{ value: 1, label: 'Loop' },
|
|
225
252
|
{ value: 2, label: 'Ping-pong' },
|
|
226
253
|
{ value: 3, label: 'Burst spread' },
|
|
227
|
-
], onChange: (mode) => binding.apply((s) => (s.emitterShape = createShape(type, { ...params, mode }))) }) }), _jsx(Row, { label: "Spread", children: _jsx(NumberField, { value: params.spread, min: 0, step: 0.05, onChange: (spread) => binding.apply((s) => (s.emitterShape = createShape(type, { ...params, spread }))) }) })] }))] }));
|
|
254
|
+
], onChange: (mode) => binding.apply((s) => (s.emitterShape = createShape(type, { ...params, mode }))) }) }), _jsx(Row, { label: "Spread", children: _jsx(NumberField, { value: params.spread, min: 0, step: 0.05, onChange: (spread) => binding.apply((s) => (s.emitterShape = createShape(type, { ...params, spread }))) }) })] })), _jsx(Row, { label: "Align to direction", children: _jsx(CheckboxField, { value: binding.system.alignToDirection, onChange: (v) => binding.apply((s) => (s.alignToDirection = v)) }) })] }));
|
|
228
255
|
}
|
|
229
256
|
export function SizeOverLifeModule({ binding }) {
|
|
230
257
|
const system = binding.system;
|
|
@@ -351,6 +378,15 @@ export function RendererModule({ binding, textureOptions, geometryOptions, resol
|
|
|
351
378
|
new ConstantValue(Math.round(v));
|
|
352
379
|
}) }) }), _jsx(Row, { label: "Follow origin", children: _jsx(CheckboxField, { value: !!system.rendererEmitterSettings.followLocalOrigin, onChange: (v) => binding.apply((s) => {
|
|
353
380
|
s.rendererEmitterSettings.followLocalOrigin = v;
|
|
381
|
+
}) }) }), [
|
|
382
|
+
['recordWorldSpace', 'Record in world space'],
|
|
383
|
+
['uvFromHead', 'UV from head'],
|
|
384
|
+
['colorFromParticle', 'Color from particle'],
|
|
385
|
+
['dieWithParticle', 'Die with particle'],
|
|
386
|
+
].map(([key, label]) => (_jsx(Row, { label: label, children: _jsx(CheckboxField, { value: !!system.rendererEmitterSettings[key], onChange: (v) => binding.apply((s) => {
|
|
387
|
+
s.rendererEmitterSettings[key] = v;
|
|
388
|
+
}) }) }, key))), _jsx(Row, { label: "Head width scale", children: _jsx(NumberField, { value: system.rendererEmitterSettings.headWidthScale ?? 1, min: 0, step: 0.1, onChange: (v) => binding.apply((s) => {
|
|
389
|
+
s.rendererEmitterSettings.headWidthScale = v;
|
|
354
390
|
}) }) })] })), system.renderMode === RenderMode.StretchedBillBoard && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Speed factor", children: _jsx(NumberField, { value: system.rendererEmitterSettings.speedFactor ?? 0, step: 0.1, onChange: (v) => binding.apply((s) => (s.rendererEmitterSettings.speedFactor = v)) }) }), _jsx(Row, { label: "Length factor", children: _jsx(NumberField, { value: system.rendererEmitterSettings.lengthFactor ?? 2, step: 0.1, onChange: (v) => binding.apply((s) => (s.rendererEmitterSettings.lengthFactor = v)) }) })] })), _jsxs("div", { style: {
|
|
355
391
|
marginTop: 10,
|
|
356
392
|
marginBottom: 2,
|
|
@@ -503,7 +539,22 @@ export function RendererModule({ binding, textureOptions, geometryOptions, resol
|
|
|
503
539
|
{ value: 'add', label: 'Add (glow)' },
|
|
504
540
|
{ value: 'mix', label: 'Mix (outline)' },
|
|
505
541
|
], 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 }) }) })] }));
|
|
506
|
-
})(), _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: "
|
|
542
|
+
})(), _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) => {
|
|
543
|
+
const next = (s.pivot ?? new Vector3()).clone();
|
|
544
|
+
next[axis] = v;
|
|
545
|
+
s.pivot = next;
|
|
546
|
+
}) }, axis))) }) })), system.renderMode === RenderMode.Mesh && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Env map atlas", children: _jsx(SelectField, { value: system.getRendererSettings().reflectionAtlas?.url ?? '', options: [
|
|
547
|
+
{ value: '', label: 'None' },
|
|
548
|
+
...(textureOptions ?? []).map((option) => ({ value: option.url, label: option.label })),
|
|
549
|
+
], onChange: (url) => patchMaterial({ reflectionAtlas: url ? loadTexture(url) : null }) }) }), _jsx(Row, { label: "Reflection level", children: _jsx(NumberField, { value: system.getRendererSettings().reflectionLevel ?? 1, min: 0, step: 0.1, onChange: (v) => patchMaterial({ reflectionLevel: Math.max(0, v) }) }) })] })), _jsx(Row, { label: "Sort mode", children: _jsx(SelectField, { value: system.sortMode, options: [
|
|
550
|
+
{ value: 'none', label: 'None' },
|
|
551
|
+
{ value: 'distance', label: 'By distance' },
|
|
552
|
+
{ value: 'oldestInFront', label: 'Oldest in front' },
|
|
553
|
+
{ value: 'youngestInFront', label: 'Youngest in front' },
|
|
554
|
+
], 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) => {
|
|
555
|
+
s.minParticleSize = 0;
|
|
556
|
+
s.maxParticleSize = v ? 0.5 : Infinity;
|
|
557
|
+
}) }) }), (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)) })] }) }))] }));
|
|
507
558
|
}
|
|
508
559
|
function removeBehavior(system, type) {
|
|
509
560
|
const behaviors = system.behaviors;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "babylon.quarks-editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"clean": "rimraf dist",
|
|
27
|
-
"build": "npm run clean && tsc -p tsconfig.json"
|
|
27
|
+
"build": "npm run clean && tsc -p tsconfig.json",
|
|
28
|
+
"test": "jest"
|
|
28
29
|
},
|
|
29
30
|
"repository": {
|
|
30
31
|
"type": "git",
|
|
@@ -59,7 +60,7 @@
|
|
|
59
60
|
"react-dom": ">=18"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
|
-
"@babylonjs/materials": "9.
|
|
63
|
+
"@babylonjs/materials": "9.31.0",
|
|
63
64
|
"@heroicons/react": "^2.2.0",
|
|
64
65
|
"@tanstack/react-virtual": "^3.14.6",
|
|
65
66
|
"@types/react": "^19.0.0",
|
package/src/core/material.ts
CHANGED
|
@@ -7,6 +7,9 @@ export interface RendererMaterialPatch {
|
|
|
7
7
|
depthWrite?: boolean;
|
|
8
8
|
alphaTest?: number;
|
|
9
9
|
texture?: unknown;
|
|
10
|
+
/** A 3×2 cube-face atlas mesh particles reflect, or null for none. */
|
|
11
|
+
reflectionAtlas?: unknown;
|
|
12
|
+
reflectionLevel?: number;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
function isEditableMaterialRecord(material: unknown): material is Record<string, unknown> {
|
|
@@ -35,6 +38,12 @@ export function applyRendererMaterial(system: ParticleSystem, patch: RendererMat
|
|
|
35
38
|
if (patch.texture !== undefined) {
|
|
36
39
|
system.texture = patch.texture as never;
|
|
37
40
|
}
|
|
41
|
+
if (patch.reflectionAtlas !== undefined) {
|
|
42
|
+
settings.reflectionAtlas = patch.reflectionAtlas as never;
|
|
43
|
+
}
|
|
44
|
+
if (patch.reflectionLevel !== undefined) {
|
|
45
|
+
settings.reflectionLevel = patch.reflectionLevel;
|
|
46
|
+
}
|
|
38
47
|
|
|
39
48
|
const material = system.material;
|
|
40
49
|
if (isEditableMaterialRecord(material)) {
|
|
@@ -58,6 +67,12 @@ export function applyRendererMaterial(system: ParticleSystem, patch: RendererMat
|
|
|
58
67
|
material.texture = patch.texture;
|
|
59
68
|
material.map = patch.texture;
|
|
60
69
|
}
|
|
70
|
+
if (patch.reflectionAtlas !== undefined) {
|
|
71
|
+
material.reflectionAtlas = patch.reflectionAtlas;
|
|
72
|
+
}
|
|
73
|
+
if (patch.reflectionLevel !== undefined) {
|
|
74
|
+
material.reflectionLevel = patch.reflectionLevel;
|
|
75
|
+
}
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
system.neededToUpdateRender = true;
|
package/src/core/shapes.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type {Mesh} from '@babylonjs/core/Meshes/mesh';
|
|
2
2
|
import type {EmitterShape} from 'babylon.quarks';
|
|
3
3
|
import {
|
|
4
|
+
BoxEmitFrom,
|
|
5
|
+
BoxEmitter,
|
|
4
6
|
CircleEmitter,
|
|
5
7
|
ConeEmitter,
|
|
8
|
+
ConstantValue,
|
|
6
9
|
DonutEmitter,
|
|
10
|
+
EdgeEmitter,
|
|
7
11
|
EmitterMode,
|
|
8
12
|
GridEmitter,
|
|
9
13
|
HemisphereEmitter,
|
|
@@ -22,6 +26,8 @@ export type ShapeType =
|
|
|
22
26
|
| 'donut'
|
|
23
27
|
| 'rectangle'
|
|
24
28
|
| 'grid'
|
|
29
|
+
| 'box'
|
|
30
|
+
| 'edge'
|
|
25
31
|
| 'mesh_surface';
|
|
26
32
|
|
|
27
33
|
export const SHAPE_TYPES: Array<{value: ShapeType; label: string}> = [
|
|
@@ -32,6 +38,8 @@ export const SHAPE_TYPES: Array<{value: ShapeType; label: string}> = [
|
|
|
32
38
|
{value: 'donut', label: 'Donut'},
|
|
33
39
|
{value: 'rectangle', label: 'Rectangle'},
|
|
34
40
|
{value: 'grid', label: 'Grid'},
|
|
41
|
+
{value: 'box', label: 'Box'},
|
|
42
|
+
{value: 'edge', label: 'Edge'},
|
|
35
43
|
{value: 'mesh_surface', label: 'Mesh surface'},
|
|
36
44
|
{value: 'point', label: 'Point'},
|
|
37
45
|
];
|
|
@@ -48,6 +56,12 @@ export interface ShapeParams {
|
|
|
48
56
|
spread: number;
|
|
49
57
|
column: number;
|
|
50
58
|
row: number;
|
|
59
|
+
/** Box depth (Z). */
|
|
60
|
+
depth: number;
|
|
61
|
+
/** Where a box emits from: its volume, its faces or its edges. */
|
|
62
|
+
emitFrom: BoxEmitFrom;
|
|
63
|
+
/** How fast an edge emitter's loop / ping-pong point travels, in lengths per second. */
|
|
64
|
+
speed: number;
|
|
51
65
|
mesh?: Mesh;
|
|
52
66
|
}
|
|
53
67
|
|
|
@@ -63,6 +77,9 @@ export const DEFAULT_SHAPE_PARAMS: ShapeParams = {
|
|
|
63
77
|
spread: 0,
|
|
64
78
|
column: 10,
|
|
65
79
|
row: 10,
|
|
80
|
+
depth: 1,
|
|
81
|
+
emitFrom: BoxEmitFrom.Volume,
|
|
82
|
+
speed: 1,
|
|
66
83
|
mesh: undefined,
|
|
67
84
|
};
|
|
68
85
|
|
|
@@ -87,6 +104,9 @@ export function readShapeParams(shape: EmitterShape): ShapeParams {
|
|
|
87
104
|
height: num('height', DEFAULT_SHAPE_PARAMS.height),
|
|
88
105
|
column: num('column', DEFAULT_SHAPE_PARAMS.column),
|
|
89
106
|
row: num('row', DEFAULT_SHAPE_PARAMS.row),
|
|
107
|
+
depth: num('depth', DEFAULT_SHAPE_PARAMS.depth),
|
|
108
|
+
emitFrom: num('emitFrom', DEFAULT_SHAPE_PARAMS.emitFrom) as BoxEmitFrom,
|
|
109
|
+
speed: anyShape.speed instanceof ConstantValue ? anyShape.speed.value : DEFAULT_SHAPE_PARAMS.speed,
|
|
90
110
|
mesh: anyShape.mesh as Mesh | undefined,
|
|
91
111
|
};
|
|
92
112
|
}
|
|
@@ -102,6 +122,8 @@ export const SHAPE_PARAM_KEYS: {[K in ShapeType]: Array<keyof ShapeParams>} = {
|
|
|
102
122
|
donut: ['radius', 'arc', 'thickness', 'donutRadius'],
|
|
103
123
|
rectangle: ['width', 'height'],
|
|
104
124
|
grid: ['width', 'height', 'column', 'row'],
|
|
125
|
+
box: ['width', 'height', 'depth'],
|
|
126
|
+
edge: ['radius', 'speed'],
|
|
105
127
|
mesh_surface: [],
|
|
106
128
|
};
|
|
107
129
|
|
|
@@ -165,6 +187,20 @@ export function createShape(type: ShapeType, params: ShapeParams): EmitterShape
|
|
|
165
187
|
column: params.column,
|
|
166
188
|
row: params.row,
|
|
167
189
|
});
|
|
190
|
+
case 'box':
|
|
191
|
+
return new BoxEmitter({
|
|
192
|
+
width: params.width,
|
|
193
|
+
height: params.height,
|
|
194
|
+
depth: params.depth,
|
|
195
|
+
emitFrom: params.emitFrom,
|
|
196
|
+
});
|
|
197
|
+
case 'edge':
|
|
198
|
+
return new EdgeEmitter({
|
|
199
|
+
radius: params.radius,
|
|
200
|
+
mode: params.mode,
|
|
201
|
+
spread: params.spread,
|
|
202
|
+
speed: new ConstantValue(params.speed),
|
|
203
|
+
});
|
|
168
204
|
case 'mesh_surface':
|
|
169
205
|
return new MeshSurfaceEmitter(params.mesh);
|
|
170
206
|
}
|
|
@@ -12,8 +12,10 @@ import {
|
|
|
12
12
|
LimitSpeedOverLifeModule,
|
|
13
13
|
NoiseModule,
|
|
14
14
|
OrbitOverLifeModule,
|
|
15
|
+
Rotation3DOverLifeModule,
|
|
15
16
|
RotationBySpeedModule,
|
|
16
17
|
RotationOverLifeModule,
|
|
18
|
+
SequencesModule,
|
|
17
19
|
SizeBySpeedModule,
|
|
18
20
|
SpeedOverLifeModule,
|
|
19
21
|
TextureSheetModule,
|
|
@@ -116,6 +118,7 @@ export const EffectEditor = React.memo(function EffectEditor(props: EffectEditor
|
|
|
116
118
|
<SizeOverLifeModule binding={binding} />
|
|
117
119
|
<SizeBySpeedModule binding={binding} />
|
|
118
120
|
<RotationOverLifeModule binding={binding} />
|
|
121
|
+
<Rotation3DOverLifeModule binding={binding} />
|
|
119
122
|
<RotationBySpeedModule binding={binding} />
|
|
120
123
|
<NoiseModule binding={binding} />
|
|
121
124
|
<TurbulenceModule binding={binding} />
|
|
@@ -123,6 +126,7 @@ export const EffectEditor = React.memo(function EffectEditor(props: EffectEditor
|
|
|
123
126
|
<WidthOverTrailModule binding={binding} />
|
|
124
127
|
<SubEmittersModule binding={binding} rootBinding={props.binding} />
|
|
125
128
|
<TextureSheetModule binding={binding} />
|
|
129
|
+
<SequencesModule binding={binding} textureOptions={props.textureOptions} />
|
|
126
130
|
<RendererModule
|
|
127
131
|
binding={binding}
|
|
128
132
|
textureOptions={props.textureOptions}
|
|
@@ -93,6 +93,21 @@ import {iconStyle, type HeroIcon} from './icons';
|
|
|
93
93
|
import type {GeometryData, GeometryOption, TextureOption} from './modules';
|
|
94
94
|
import {buttonStyle, globalEditorStyles, theme} from './theme';
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The effect's JSON, with a warning when it points at object URLs: a texture
|
|
98
|
+
* dropped in from disk lives at a `blob:` URL that dies with the page, so the
|
|
99
|
+
* exported effect would load without it.
|
|
100
|
+
*/
|
|
101
|
+
function exportEffectJSON(binding: EffectBinding, warn: (message: string) => void): string {
|
|
102
|
+
const json = binding.exportJSON('EditorEffect');
|
|
103
|
+
if (json.includes('"blob:')) {
|
|
104
|
+
warn(
|
|
105
|
+
'This effect references blob: textures, which will not load after a reload. Re-pick them from a hosted URL.'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return json;
|
|
109
|
+
}
|
|
110
|
+
|
|
96
111
|
export interface EffectEditorHostHandle {
|
|
97
112
|
binding: EffectBinding;
|
|
98
113
|
history: EffectHistory;
|
|
@@ -2139,7 +2154,7 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
|
|
|
2139
2154
|
disabled={!binding}
|
|
2140
2155
|
onClick={() => {
|
|
2141
2156
|
if (!binding) return;
|
|
2142
|
-
const blob = new Blob([binding
|
|
2157
|
+
const blob = new Blob([exportEffectJSON(binding, showToast)], {
|
|
2143
2158
|
type: 'application/json',
|
|
2144
2159
|
});
|
|
2145
2160
|
const link = document.createElement('a');
|
|
@@ -2156,7 +2171,7 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
|
|
|
2156
2171
|
className="qe-hover"
|
|
2157
2172
|
style={buttonStyle}
|
|
2158
2173
|
disabled={!binding}
|
|
2159
|
-
onClick={() => binding && props.onSave!(binding
|
|
2174
|
+
onClick={() => binding && props.onSave!(exportEffectJSON(binding, showToast))}
|
|
2160
2175
|
>
|
|
2161
2176
|
Save
|
|
2162
2177
|
</button>
|
|
@@ -69,6 +69,21 @@ export function SubEmittersModule(props: {binding: EffectBinding; rootBinding: E
|
|
|
69
69
|
onChange={(v) => binding.apply(() => (behavior.useVelocityAsBasis = v))}
|
|
70
70
|
/>
|
|
71
71
|
</Row>
|
|
72
|
+
{(['color', 'size', 'rotation', 'lifetime'] as const).map((property) => (
|
|
73
|
+
<Row key={property} label={`Inherit ${property}`}>
|
|
74
|
+
<CheckboxField
|
|
75
|
+
value={behavior.inherit.includes(property)}
|
|
76
|
+
onChange={(v) =>
|
|
77
|
+
binding.apply(
|
|
78
|
+
() =>
|
|
79
|
+
(behavior.inherit = v
|
|
80
|
+
? [...behavior.inherit.filter((p) => p !== property), property]
|
|
81
|
+
: behavior.inherit.filter((p) => p !== property))
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
/>
|
|
85
|
+
</Row>
|
|
86
|
+
))}
|
|
72
87
|
<p style={{margin: '8px 0 2px', color: theme.textDim, fontSize: 11.5}}>
|
|
73
88
|
Spawns “{target?.emitter.name ?? 'sub-emitter'}” — select it in the hierarchy above to edit it.
|
|
74
89
|
</p>
|
package/src/react/ValueField.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import React from 'react';
|
|
1
2
|
import {ScalarGenerator, ScalarValueState, buildCurve, buildScalar, readPieces, readScalar} from '../core/values';
|
|
2
3
|
import {CurveEditor} from './CurveEditor';
|
|
3
4
|
import {NumberField, Row, SelectField} from './fields';
|
|
@@ -6,6 +7,8 @@ import {NumberField, Row, SelectField} from './fields';
|
|
|
6
7
|
* Unity-like value field: Constant | Random Between Two Constants | Curve,
|
|
7
8
|
* bound to a quarks value generator.
|
|
8
9
|
*/
|
|
10
|
+
const captionStyle: React.CSSProperties = {fontSize: 10.5, opacity: 0.6, flex: 'none'};
|
|
11
|
+
|
|
9
12
|
export function ValueField(props: {
|
|
10
13
|
label: string;
|
|
11
14
|
hint?: string;
|
|
@@ -41,8 +44,10 @@ export function ValueField(props: {
|
|
|
41
44
|
)}
|
|
42
45
|
{state.mode === 'random' && (
|
|
43
46
|
<Row label="">
|
|
44
|
-
<div style={{display: 'flex', gap: 6}}>
|
|
47
|
+
<div style={{display: 'flex', gap: 6, alignItems: 'center'}}>
|
|
48
|
+
<span style={captionStyle}>min</span>
|
|
45
49
|
<NumberField value={state.min} min={props.min} onChange={(min) => commit({min})} />
|
|
50
|
+
<span style={captionStyle}>max</span>
|
|
46
51
|
<NumberField value={state.max} min={props.min} onChange={(max) => commit({max})} />
|
|
47
52
|
</div>
|
|
48
53
|
</Row>
|