babylon.quarks-editor 0.17.7
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 +13 -0
- package/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/core/binding.d.ts +43 -0
- package/dist/core/binding.js +82 -0
- package/dist/core/collision.d.ts +20 -0
- package/dist/core/collision.js +39 -0
- package/dist/core/colors.d.ts +31 -0
- package/dist/core/colors.js +60 -0
- package/dist/core/effectTree.d.ts +25 -0
- package/dist/core/effectTree.js +115 -0
- package/dist/core/groups.d.ts +12 -0
- package/dist/core/groups.js +27 -0
- package/dist/core/history.d.ts +25 -0
- package/dist/core/history.js +69 -0
- package/dist/core/loadEffect.d.ts +8 -0
- package/dist/core/loadEffect.js +87 -0
- package/dist/core/scrub.d.ts +11 -0
- package/dist/core/scrub.js +24 -0
- package/dist/core/shapes.d.ts +31 -0
- package/dist/core/shapes.js +98 -0
- package/dist/core/systems.d.ts +25 -0
- package/dist/core/systems.js +76 -0
- package/dist/core/textureExport.d.ts +9 -0
- package/dist/core/textureExport.js +58 -0
- package/dist/core/timeline.d.ts +35 -0
- package/dist/core/timeline.js +69 -0
- package/dist/core/values.d.ts +31 -0
- package/dist/core/values.js +81 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +9 -0
- package/dist/react/CurveEditor.d.ts +12 -0
- package/dist/react/CurveEditor.js +106 -0
- package/dist/react/EffectEditor.d.ts +22 -0
- package/dist/react/EffectEditor.js +30 -0
- package/dist/react/EffectEditorHost.d.ts +42 -0
- package/dist/react/EffectEditorHost.js +247 -0
- package/dist/react/GradientEditor.d.ts +7 -0
- package/dist/react/GradientEditor.js +72 -0
- package/dist/react/ModuleSection.d.ts +9 -0
- package/dist/react/ModuleSection.js +24 -0
- package/dist/react/PromptDialog.d.ts +14 -0
- package/dist/react/PromptDialog.js +45 -0
- package/dist/react/SubEmittersModule.d.ts +10 -0
- package/dist/react/SubEmittersModule.js +39 -0
- package/dist/react/TimelinePanel.d.ts +32 -0
- package/dist/react/TimelinePanel.js +225 -0
- package/dist/react/TimelineTrackRow.d.ts +23 -0
- package/dist/react/TimelineTrackRow.js +107 -0
- package/dist/react/ValueField.d.ts +13 -0
- package/dist/react/ValueField.js +19 -0
- package/dist/react/behaviorModules.d.ts +23 -0
- package/dist/react/behaviorModules.js +139 -0
- package/dist/react/fields.d.ts +24 -0
- package/dist/react/fields.js +30 -0
- package/dist/react/index.d.ts +20 -0
- package/dist/react/index.js +14 -0
- package/dist/react/modules.d.ts +35 -0
- package/dist/react/modules.js +439 -0
- package/dist/react/theme.d.ts +26 -0
- package/dist/react/theme.js +78 -0
- package/package.json +63 -0
- package/src/core/binding.ts +105 -0
- package/src/core/collision.ts +40 -0
- package/src/core/colors.ts +84 -0
- package/src/core/effectTree.ts +143 -0
- package/src/core/groups.ts +29 -0
- package/src/core/history.ts +77 -0
- package/src/core/loadEffect.ts +102 -0
- package/src/core/scrub.ts +28 -0
- package/src/core/shapes.ts +133 -0
- package/src/core/systems.ts +98 -0
- package/src/core/textureExport.ts +63 -0
- package/src/core/timeline.ts +94 -0
- package/src/core/values.ts +113 -0
- package/src/index.ts +32 -0
- package/src/react/CurveEditor.tsx +168 -0
- package/src/react/EffectEditor.tsx +111 -0
- package/src/react/EffectEditorHost.tsx +377 -0
- package/src/react/GradientEditor.tsx +127 -0
- package/src/react/ModuleSection.tsx +54 -0
- package/src/react/PromptDialog.tsx +85 -0
- package/src/react/SubEmittersModule.tsx +80 -0
- package/src/react/TimelinePanel.tsx +354 -0
- package/src/react/TimelineTrackRow.tsx +267 -0
- package/src/react/ValueField.tsx +60 -0
- package/src/react/behaviorModules.tsx +658 -0
- package/src/react/fields.tsx +76 -0
- package/src/react/index.ts +43 -0
- package/src/react/modules.tsx +1034 -0
- package/src/react/theme.ts +85 -0
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
import React, {useState} from 'react';
|
|
2
|
+
import {Mesh} from '@babylonjs/core/Meshes/mesh';
|
|
3
|
+
import {
|
|
4
|
+
ColorOverLife,
|
|
5
|
+
ConstantColor,
|
|
6
|
+
ConstantValue,
|
|
7
|
+
EulerGenerator,
|
|
8
|
+
Gradient,
|
|
9
|
+
IntervalValue,
|
|
10
|
+
RandomColor,
|
|
11
|
+
RandomColorBetweenGradient,
|
|
12
|
+
RandomQuatGenerator,
|
|
13
|
+
RenderMode,
|
|
14
|
+
SizeOverLife,
|
|
15
|
+
Vector3Function,
|
|
16
|
+
Vector4,
|
|
17
|
+
} from 'babylon.quarks';
|
|
18
|
+
import type {Behavior, ParticleSystem} from 'babylon.quarks';
|
|
19
|
+
import {EffectBinding} from '../core/binding';
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_GRADIENT_STOPS,
|
|
22
|
+
GradientStop,
|
|
23
|
+
buildGradient,
|
|
24
|
+
findBehavior,
|
|
25
|
+
hexToRgb,
|
|
26
|
+
readGradientStops,
|
|
27
|
+
rgbToHex,
|
|
28
|
+
} from '../core/colors';
|
|
29
|
+
import {DEFAULT_SHAPE_PARAMS, SHAPE_PARAM_KEYS, SHAPE_TYPES, createShape, getShapeType, readShapeParams} from '../core/shapes';
|
|
30
|
+
import type {ShapeType} from '../core/shapes';
|
|
31
|
+
import {buildCurve, buildScalar, readPieces, readScalar} from '../core/values';
|
|
32
|
+
import {CurveEditor} from './CurveEditor';
|
|
33
|
+
import {GradientEditor} from './GradientEditor';
|
|
34
|
+
import {ModuleSection} from './ModuleSection';
|
|
35
|
+
import {PromptDialog} from './PromptDialog';
|
|
36
|
+
import {CheckboxField, NumberField, Row, SelectField} from './fields';
|
|
37
|
+
import {ValueField} from './ValueField';
|
|
38
|
+
|
|
39
|
+
interface ModuleProps {
|
|
40
|
+
binding: EffectBinding;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const MESH_QUAD_POSITIONS = new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]);
|
|
44
|
+
const MESH_QUAD_INDICES = new Uint32Array([0, 1, 2, 0, 2, 3]);
|
|
45
|
+
const MESH_QUAD_UVS = new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]);
|
|
46
|
+
const MESH_CUBE_POSITIONS = new Float32Array([
|
|
47
|
+
-0.5, -0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, -0.5,
|
|
48
|
+
-0.5, -0.5, 0.5, 0.5, -0.5, 0.5, 0.5, 0.5, 0.5, -0.5, 0.5, 0.5,
|
|
49
|
+
]);
|
|
50
|
+
const MESH_CUBE_INDICES = new Uint32Array([
|
|
51
|
+
0, 1, 2, 0, 2, 3, 4, 6, 5, 4, 7, 6, 0, 4, 5, 0, 5, 1,
|
|
52
|
+
3, 2, 6, 3, 6, 7, 0, 3, 7, 0, 7, 4, 1, 5, 6, 1, 6, 2,
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
function arraysEqual(a: ArrayLike<number>, b: ArrayLike<number>): boolean {
|
|
56
|
+
if (a.length !== b.length) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
for (let i = 0; i < a.length; i++) {
|
|
60
|
+
if (a[i] !== b[i]) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Detects whether the current mesh geometry is one of the editor's built-in presets or was imported. */
|
|
68
|
+
function detectGeometryPreset(positions: Float32Array): 'cube' | 'quad' | 'custom' {
|
|
69
|
+
if (arraysEqual(positions, MESH_CUBE_POSITIONS)) {
|
|
70
|
+
return 'cube';
|
|
71
|
+
}
|
|
72
|
+
if (arraysEqual(positions, MESH_QUAD_POSITIONS)) {
|
|
73
|
+
return 'quad';
|
|
74
|
+
}
|
|
75
|
+
return 'custom';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const PARAM_LABELS: {[key: string]: string} = {
|
|
79
|
+
radius: 'Radius',
|
|
80
|
+
arc: 'Arc',
|
|
81
|
+
thickness: 'Thickness',
|
|
82
|
+
angle: 'Angle',
|
|
83
|
+
donutRadius: 'Donut radius',
|
|
84
|
+
width: 'Width',
|
|
85
|
+
height: 'Height',
|
|
86
|
+
column: 'Columns',
|
|
87
|
+
row: 'Rows',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function ColorInput(props: {value: Vector4; onChange: (next: Vector4) => void}) {
|
|
91
|
+
const {value, onChange} = props;
|
|
92
|
+
return (
|
|
93
|
+
<div style={{display: 'flex', gap: 6, alignItems: 'center'}}>
|
|
94
|
+
<input
|
|
95
|
+
type="color"
|
|
96
|
+
className="qe-hover"
|
|
97
|
+
value={rgbToHex(value.x, value.y, value.z)}
|
|
98
|
+
onChange={(e) => {
|
|
99
|
+
const rgb = hexToRgb(e.target.value);
|
|
100
|
+
onChange(new Vector4(rgb.r, rgb.g, rgb.b, value.w));
|
|
101
|
+
}}
|
|
102
|
+
style={{width: 42, height: 24, border: 'none', background: 'transparent', padding: 0, cursor: 'pointer'}}
|
|
103
|
+
/>
|
|
104
|
+
<NumberField
|
|
105
|
+
value={value.w}
|
|
106
|
+
min={0}
|
|
107
|
+
step={0.05}
|
|
108
|
+
onChange={(a) => onChange(new Vector4(value.x, value.y, value.z, Math.min(1, a)))}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function MainModule({binding}: ModuleProps) {
|
|
115
|
+
const system = binding.system;
|
|
116
|
+
const startColor = system.startColor;
|
|
117
|
+
const colorMode = startColor instanceof RandomColor ? 'random' : 'constant';
|
|
118
|
+
const colorA =
|
|
119
|
+
startColor instanceof RandomColor ? (startColor as never as {a: Vector4}).a
|
|
120
|
+
: startColor instanceof ConstantColor ? startColor.color
|
|
121
|
+
: new Vector4(1, 1, 1, 1);
|
|
122
|
+
const colorB = startColor instanceof RandomColor ? (startColor as never as {b: Vector4}).b : new Vector4(1, 0.5, 0.2, 1);
|
|
123
|
+
const rotation = system.startRotation;
|
|
124
|
+
const isEuler = rotation instanceof EulerGenerator;
|
|
125
|
+
const rotationMode =
|
|
126
|
+
rotation instanceof RandomQuatGenerator ? '3d'
|
|
127
|
+
: isEuler ? 'euler'
|
|
128
|
+
: readScalar(rotation as never).mode === 'random' ? 'random'
|
|
129
|
+
: 'angle';
|
|
130
|
+
const rotationState = readScalar(rotation instanceof RandomQuatGenerator || isEuler ? undefined : (rotation as never));
|
|
131
|
+
const eulerAngles = {
|
|
132
|
+
x: isEuler ? readScalar((rotation as EulerGenerator).angleX as never).value : 0,
|
|
133
|
+
y: isEuler ? readScalar((rotation as EulerGenerator).angleY as never).value : 0,
|
|
134
|
+
z: isEuler ? readScalar((rotation as EulerGenerator).angleZ as never).value : 0,
|
|
135
|
+
};
|
|
136
|
+
const setEulerAxis = (axis: 'x' | 'y' | 'z', v: number) =>
|
|
137
|
+
binding.apply((s) => {
|
|
138
|
+
const cur = s.startRotation as EulerGenerator;
|
|
139
|
+
s.startRotation = new EulerGenerator(
|
|
140
|
+
axis === 'x' ? new ConstantValue(v) : cur.angleX,
|
|
141
|
+
axis === 'y' ? new ConstantValue(v) : cur.angleY,
|
|
142
|
+
axis === 'z' ? new ConstantValue(v) : cur.angleZ
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
const startSize = system.startSize as unknown as {type?: string; x?: never; y?: never; z?: never};
|
|
146
|
+
const size3D = startSize.type === 'vec3function';
|
|
147
|
+
const colorGenMode =
|
|
148
|
+
startColor instanceof RandomColorBetweenGradient ? 'gradient2'
|
|
149
|
+
: startColor instanceof Gradient ? 'gradient'
|
|
150
|
+
: colorMode;
|
|
151
|
+
return (
|
|
152
|
+
<ModuleSection title="Main">
|
|
153
|
+
<Row label="Duration">
|
|
154
|
+
<NumberField value={system.duration} min={0.05} onChange={(v) => binding.apply((s) => (s.duration = v))} />
|
|
155
|
+
</Row>
|
|
156
|
+
<Row label="Looping">
|
|
157
|
+
<CheckboxField value={system.looping} onChange={(v) => binding.apply((s) => (s.looping = v))} />
|
|
158
|
+
</Row>
|
|
159
|
+
<Row label="World space">
|
|
160
|
+
<CheckboxField value={system.worldSpace} onChange={(v) => binding.apply((s) => (s.worldSpace = v))} />
|
|
161
|
+
</Row>
|
|
162
|
+
<Row label="Prewarm">
|
|
163
|
+
<CheckboxField value={system.prewarm} onChange={(v) => binding.apply((s) => (s.prewarm = v))} />
|
|
164
|
+
</Row>
|
|
165
|
+
<ValueField
|
|
166
|
+
label="Start delay"
|
|
167
|
+
generator={system.startDelay}
|
|
168
|
+
min={0}
|
|
169
|
+
curveMax={5}
|
|
170
|
+
onChange={(g) =>
|
|
171
|
+
binding.apply((s) => {
|
|
172
|
+
s.startDelay = g;
|
|
173
|
+
s.restart();
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
/>
|
|
177
|
+
<ValueField
|
|
178
|
+
label="Start lifetime"
|
|
179
|
+
generator={system.startLife}
|
|
180
|
+
min={0.01}
|
|
181
|
+
curveMax={10}
|
|
182
|
+
onChange={(g) => binding.apply((s) => (s.startLife = g))}
|
|
183
|
+
/>
|
|
184
|
+
<ValueField
|
|
185
|
+
label="Start speed"
|
|
186
|
+
generator={system.startSpeed}
|
|
187
|
+
min={0}
|
|
188
|
+
curveMax={20}
|
|
189
|
+
onChange={(g) => binding.apply((s) => (s.startSpeed = g))}
|
|
190
|
+
/>
|
|
191
|
+
<Row label="Start size">
|
|
192
|
+
<SelectField
|
|
193
|
+
value={size3D ? '3d' : 'uniform'}
|
|
194
|
+
options={[
|
|
195
|
+
{value: 'uniform', label: 'Uniform'},
|
|
196
|
+
{value: '3d', label: '3D (per axis)'},
|
|
197
|
+
]}
|
|
198
|
+
onChange={(mode) =>
|
|
199
|
+
binding.apply((s) => {
|
|
200
|
+
const cur = s.startSize as never as {clone: () => never; x?: never};
|
|
201
|
+
if (mode === '3d') {
|
|
202
|
+
// Seed all three axes from the current uniform generator.
|
|
203
|
+
s.startSize = new Vector3Function(cur.clone(), cur.clone(), cur.clone());
|
|
204
|
+
} else {
|
|
205
|
+
// Collapse back to the X-axis generator.
|
|
206
|
+
s.startSize = (s.startSize as never as {x: never}).x;
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
/>
|
|
211
|
+
</Row>
|
|
212
|
+
{!size3D && (
|
|
213
|
+
<ValueField
|
|
214
|
+
label=""
|
|
215
|
+
generator={system.startSize as never}
|
|
216
|
+
min={0}
|
|
217
|
+
curveMax={5}
|
|
218
|
+
onChange={(g) => binding.apply((s) => (s.startSize = g))}
|
|
219
|
+
/>
|
|
220
|
+
)}
|
|
221
|
+
{size3D && (
|
|
222
|
+
<>
|
|
223
|
+
{(['x', 'y', 'z'] as const).map((axis) => (
|
|
224
|
+
<ValueField
|
|
225
|
+
key={axis}
|
|
226
|
+
label={axis.toUpperCase()}
|
|
227
|
+
generator={startSize[axis] as never}
|
|
228
|
+
min={0}
|
|
229
|
+
curveMax={5}
|
|
230
|
+
onChange={(g) =>
|
|
231
|
+
binding.apply((s) => {
|
|
232
|
+
const cur = s.startSize as Vector3Function;
|
|
233
|
+
s.startSize = new Vector3Function(
|
|
234
|
+
axis === 'x' ? (g as never) : cur.x,
|
|
235
|
+
axis === 'y' ? (g as never) : cur.y,
|
|
236
|
+
axis === 'z' ? (g as never) : cur.z
|
|
237
|
+
);
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
/>
|
|
241
|
+
))}
|
|
242
|
+
</>
|
|
243
|
+
)}
|
|
244
|
+
<Row label="Start color">
|
|
245
|
+
<SelectField
|
|
246
|
+
value={colorGenMode}
|
|
247
|
+
options={[
|
|
248
|
+
{value: 'constant', label: 'Constant'},
|
|
249
|
+
{value: 'random', label: 'Random between two'},
|
|
250
|
+
{value: 'gradient', label: 'Gradient'},
|
|
251
|
+
{value: 'gradient2', label: 'Random between gradients'},
|
|
252
|
+
]}
|
|
253
|
+
onChange={(mode) =>
|
|
254
|
+
binding.apply((s) => {
|
|
255
|
+
if (mode === 'random') {
|
|
256
|
+
s.startColor = new RandomColor(colorA, colorB);
|
|
257
|
+
} else if (mode === 'gradient') {
|
|
258
|
+
s.startColor = buildGradient(DEFAULT_GRADIENT_STOPS);
|
|
259
|
+
} else if (mode === 'gradient2') {
|
|
260
|
+
s.startColor = new RandomColorBetweenGradient(
|
|
261
|
+
buildGradient(DEFAULT_GRADIENT_STOPS),
|
|
262
|
+
buildGradient(DEFAULT_GRADIENT_STOPS)
|
|
263
|
+
);
|
|
264
|
+
} else {
|
|
265
|
+
s.startColor = new ConstantColor(colorA);
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
/>
|
|
270
|
+
</Row>
|
|
271
|
+
{(colorGenMode === 'constant' || colorGenMode === 'random') && (
|
|
272
|
+
<Row label="">
|
|
273
|
+
<ColorInput
|
|
274
|
+
value={colorA}
|
|
275
|
+
onChange={(next) =>
|
|
276
|
+
binding.apply((s) => {
|
|
277
|
+
s.startColor = colorGenMode === 'random' ? new RandomColor(next, colorB) : new ConstantColor(next);
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
/>
|
|
281
|
+
</Row>
|
|
282
|
+
)}
|
|
283
|
+
{colorGenMode === 'random' && (
|
|
284
|
+
<Row label="">
|
|
285
|
+
<ColorInput
|
|
286
|
+
value={colorB}
|
|
287
|
+
onChange={(next) => binding.apply((s) => (s.startColor = new RandomColor(colorA, next)))}
|
|
288
|
+
/>
|
|
289
|
+
</Row>
|
|
290
|
+
)}
|
|
291
|
+
{colorGenMode === 'gradient' && (
|
|
292
|
+
<div style={{marginTop: 6}}>
|
|
293
|
+
<GradientEditor
|
|
294
|
+
stops={readGradientStops(startColor as Gradient)}
|
|
295
|
+
onChange={(stops) => binding.apply((s) => (s.startColor = buildGradient(stops)))}
|
|
296
|
+
/>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
{colorGenMode === 'gradient2' && (
|
|
300
|
+
<div style={{marginTop: 6, display: 'flex', flexDirection: 'column', gap: 6}}>
|
|
301
|
+
<GradientEditor
|
|
302
|
+
stops={readGradientStops((startColor as RandomColorBetweenGradient).gradient1)}
|
|
303
|
+
onChange={(stops) =>
|
|
304
|
+
binding.apply((s) => {
|
|
305
|
+
const g = s.startColor as RandomColorBetweenGradient;
|
|
306
|
+
s.startColor = new RandomColorBetweenGradient(buildGradient(stops), g.gradient2);
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
/>
|
|
310
|
+
<GradientEditor
|
|
311
|
+
stops={readGradientStops((startColor as RandomColorBetweenGradient).gradient2)}
|
|
312
|
+
onChange={(stops) =>
|
|
313
|
+
binding.apply((s) => {
|
|
314
|
+
const g = s.startColor as RandomColorBetweenGradient;
|
|
315
|
+
s.startColor = new RandomColorBetweenGradient(g.gradient1, buildGradient(stops));
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
/>
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
<Row label="Start rotation">
|
|
322
|
+
<SelectField
|
|
323
|
+
value={rotationMode}
|
|
324
|
+
options={[
|
|
325
|
+
{value: 'angle', label: 'Angle (rad)'},
|
|
326
|
+
{value: 'random', label: 'Random angle'},
|
|
327
|
+
{value: 'euler', label: '3D (per axis)'},
|
|
328
|
+
{value: '3d', label: 'Random 3D'},
|
|
329
|
+
]}
|
|
330
|
+
onChange={(mode) =>
|
|
331
|
+
binding.apply((s) => {
|
|
332
|
+
s.startRotation =
|
|
333
|
+
mode === '3d'
|
|
334
|
+
? new RandomQuatGenerator()
|
|
335
|
+
: mode === 'euler'
|
|
336
|
+
? new EulerGenerator(new ConstantValue(eulerAngles.x), new ConstantValue(eulerAngles.y), new ConstantValue(eulerAngles.z))
|
|
337
|
+
: mode === 'random'
|
|
338
|
+
? new IntervalValue(0, Math.PI * 2)
|
|
339
|
+
: new ConstantValue(rotationState.value);
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
/>
|
|
343
|
+
</Row>
|
|
344
|
+
{rotationMode === 'euler' && (
|
|
345
|
+
<>
|
|
346
|
+
{(['x', 'y', 'z'] as const).map((axis) => (
|
|
347
|
+
<Row key={axis} label={` ${axis.toUpperCase()} (rad)`}>
|
|
348
|
+
<NumberField value={eulerAngles[axis]} step={0.1} onChange={(v) => setEulerAxis(axis, v)} />
|
|
349
|
+
</Row>
|
|
350
|
+
))}
|
|
351
|
+
</>
|
|
352
|
+
)}
|
|
353
|
+
{rotationMode === 'angle' && (
|
|
354
|
+
<Row label="">
|
|
355
|
+
<NumberField
|
|
356
|
+
value={rotationState.value}
|
|
357
|
+
step={0.1}
|
|
358
|
+
onChange={(v) => binding.apply((s) => (s.startRotation = new ConstantValue(v)))}
|
|
359
|
+
/>
|
|
360
|
+
</Row>
|
|
361
|
+
)}
|
|
362
|
+
{rotationMode === 'random' && (
|
|
363
|
+
<Row label="">
|
|
364
|
+
<div style={{display: 'flex', gap: 6}}>
|
|
365
|
+
<NumberField
|
|
366
|
+
value={rotationState.min}
|
|
367
|
+
step={0.1}
|
|
368
|
+
onChange={(v) => binding.apply((s) => (s.startRotation = new IntervalValue(v, rotationState.max)))}
|
|
369
|
+
/>
|
|
370
|
+
<NumberField
|
|
371
|
+
value={rotationState.max}
|
|
372
|
+
step={0.1}
|
|
373
|
+
onChange={(v) => binding.apply((s) => (s.startRotation = new IntervalValue(rotationState.min, v)))}
|
|
374
|
+
/>
|
|
375
|
+
</div>
|
|
376
|
+
</Row>
|
|
377
|
+
)}
|
|
378
|
+
</ModuleSection>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function EmissionModule({binding}: ModuleProps) {
|
|
383
|
+
const system = binding.system;
|
|
384
|
+
const bursts = system.emissionBursts ?? [];
|
|
385
|
+
return (
|
|
386
|
+
<ModuleSection title="Emission">
|
|
387
|
+
<ValueField
|
|
388
|
+
label="Rate over time"
|
|
389
|
+
generator={system.emissionOverTime}
|
|
390
|
+
min={0}
|
|
391
|
+
curveMax={500}
|
|
392
|
+
onChange={(g) => binding.apply((s) => (s.emissionOverTime = g))}
|
|
393
|
+
/>
|
|
394
|
+
<ValueField
|
|
395
|
+
label="Rate over distance"
|
|
396
|
+
generator={system.emissionOverDistance}
|
|
397
|
+
min={0}
|
|
398
|
+
curveMax={50}
|
|
399
|
+
onChange={(g) => binding.apply((s) => (s.emissionOverDistance = g))}
|
|
400
|
+
/>
|
|
401
|
+
{bursts.map((burst, i) => (
|
|
402
|
+
<div key={i} style={{marginTop: 8, padding: '6px 8px', border: '1px solid #22305c', borderRadius: 8}}>
|
|
403
|
+
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}>
|
|
404
|
+
<span style={{fontSize: 11.5, color: '#9eb9ff'}}>Burst {i + 1}</span>
|
|
405
|
+
<button
|
|
406
|
+
className="qe-hover"
|
|
407
|
+
style={{background: 'none', border: 'none', color: '#e08c8c', cursor: 'pointer', fontSize: 13}}
|
|
408
|
+
title="Remove burst"
|
|
409
|
+
onClick={() => binding.apply((s) => s.emissionBursts.splice(i, 1))}
|
|
410
|
+
>
|
|
411
|
+
✕
|
|
412
|
+
</button>
|
|
413
|
+
</div>
|
|
414
|
+
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginTop: 4}}>
|
|
415
|
+
<label style={{fontSize: 10.5, color: '#b7c6ea'}}>
|
|
416
|
+
Time
|
|
417
|
+
<NumberField value={burst.time} min={0} onChange={(time) => binding.apply(() => (burst.time = time))} />
|
|
418
|
+
</label>
|
|
419
|
+
<label style={{fontSize: 10.5, color: '#b7c6ea'}}>
|
|
420
|
+
Count
|
|
421
|
+
<NumberField
|
|
422
|
+
value={readScalar(burst.count as never).value}
|
|
423
|
+
min={0}
|
|
424
|
+
step={1}
|
|
425
|
+
onChange={(count) => binding.apply(() => (burst.count = new ConstantValue(Math.round(count))))}
|
|
426
|
+
/>
|
|
427
|
+
</label>
|
|
428
|
+
<label style={{fontSize: 10.5, color: '#b7c6ea'}}>
|
|
429
|
+
Cycles
|
|
430
|
+
<NumberField
|
|
431
|
+
value={burst.cycle}
|
|
432
|
+
min={1}
|
|
433
|
+
step={1}
|
|
434
|
+
onChange={(v) => binding.apply(() => (burst.cycle = Math.round(v)))}
|
|
435
|
+
/>
|
|
436
|
+
</label>
|
|
437
|
+
<label style={{fontSize: 10.5, color: '#b7c6ea'}}>
|
|
438
|
+
Interval
|
|
439
|
+
<NumberField value={burst.interval} min={0.01} onChange={(v) => binding.apply(() => (burst.interval = v))} />
|
|
440
|
+
</label>
|
|
441
|
+
<label style={{fontSize: 10.5, color: '#b7c6ea'}}>
|
|
442
|
+
Probability
|
|
443
|
+
<NumberField
|
|
444
|
+
value={burst.probability}
|
|
445
|
+
min={0}
|
|
446
|
+
step={0.05}
|
|
447
|
+
onChange={(v) => binding.apply(() => (burst.probability = Math.min(1, v)))}
|
|
448
|
+
/>
|
|
449
|
+
</label>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
))}
|
|
453
|
+
<button
|
|
454
|
+
className="qe-hover"
|
|
455
|
+
style={{marginTop: 8, background: 'none', border: '1px dashed #34477f', color: '#9eb9ff', borderRadius: 8, padding: '5px 10px', cursor: 'pointer', fontSize: 12.5}}
|
|
456
|
+
onClick={() =>
|
|
457
|
+
binding.apply((s) =>
|
|
458
|
+
s.emissionBursts.push({time: 0, count: new ConstantValue(20), cycle: 1, interval: 0.01, probability: 1})
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
>
|
|
462
|
+
+ Add burst
|
|
463
|
+
</button>
|
|
464
|
+
</ModuleSection>
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Shapes whose constructor doesn't accept `mode`/`spread` at all — showing those controls
|
|
469
|
+
// for them would edit a value the shape silently ignores, just like the old "default to
|
|
470
|
+
// first texture" bug: a control that looks live but does nothing.
|
|
471
|
+
const SHAPES_WITHOUT_MODE_SPREAD = new Set<ShapeType>(['point', 'grid', 'mesh_surface']);
|
|
472
|
+
|
|
473
|
+
export function ShapeModule({binding}: ModuleProps) {
|
|
474
|
+
const system = binding.system;
|
|
475
|
+
const type = getShapeType(system.emitterShape);
|
|
476
|
+
const params = {...DEFAULT_SHAPE_PARAMS, ...readShapeParams(system.emitterShape)};
|
|
477
|
+
const keys = SHAPE_PARAM_KEYS[type];
|
|
478
|
+
const scene = system.emitter.getScene();
|
|
479
|
+
// 'vfxBatch' is the renderer's own internal instancing mesh — never a valid emission source.
|
|
480
|
+
const meshOptions = scene.meshes.filter(
|
|
481
|
+
(m): m is Mesh => m instanceof Mesh && m.name !== 'vfxBatch' && m.getTotalVertices() > 0
|
|
482
|
+
);
|
|
483
|
+
return (
|
|
484
|
+
<ModuleSection title="Shape">
|
|
485
|
+
<Row label="Shape">
|
|
486
|
+
<SelectField
|
|
487
|
+
value={type}
|
|
488
|
+
options={SHAPE_TYPES}
|
|
489
|
+
onChange={(next) => binding.apply((s) => (s.emitterShape = createShape(next, params)))}
|
|
490
|
+
/>
|
|
491
|
+
</Row>
|
|
492
|
+
{keys.map((key) => (
|
|
493
|
+
<Row key={key} label={PARAM_LABELS[key]}>
|
|
494
|
+
<NumberField
|
|
495
|
+
value={params[key] as number}
|
|
496
|
+
min={0}
|
|
497
|
+
onChange={(v) => binding.apply((s) => (s.emitterShape = createShape(type, {...params, [key]: v})))}
|
|
498
|
+
/>
|
|
499
|
+
</Row>
|
|
500
|
+
))}
|
|
501
|
+
{type === 'mesh_surface' && (
|
|
502
|
+
<Row label="Source mesh">
|
|
503
|
+
<SelectField
|
|
504
|
+
value={params.mesh?.uniqueId != null ? String(params.mesh.uniqueId) : '__none'}
|
|
505
|
+
options={[
|
|
506
|
+
{value: '__none', label: '(none)'},
|
|
507
|
+
...meshOptions.map((m) => ({value: String(m.uniqueId), label: m.name})),
|
|
508
|
+
]}
|
|
509
|
+
onChange={(id) => {
|
|
510
|
+
const mesh = id === '__none' ? undefined : meshOptions.find((m) => String(m.uniqueId) === id);
|
|
511
|
+
binding.apply((s) => (s.emitterShape = createShape('mesh_surface', {...params, mesh})));
|
|
512
|
+
}}
|
|
513
|
+
/>
|
|
514
|
+
</Row>
|
|
515
|
+
)}
|
|
516
|
+
{!SHAPES_WITHOUT_MODE_SPREAD.has(type) && (
|
|
517
|
+
<>
|
|
518
|
+
<Row label="Emit mode">
|
|
519
|
+
<SelectField
|
|
520
|
+
value={params.mode}
|
|
521
|
+
options={[
|
|
522
|
+
{value: 0, label: 'Random'},
|
|
523
|
+
{value: 1, label: 'Loop'},
|
|
524
|
+
{value: 2, label: 'Ping-pong'},
|
|
525
|
+
{value: 3, label: 'Burst spread'},
|
|
526
|
+
]}
|
|
527
|
+
onChange={(mode) => binding.apply((s) => (s.emitterShape = createShape(type, {...params, mode})))}
|
|
528
|
+
/>
|
|
529
|
+
</Row>
|
|
530
|
+
<Row label="Spread">
|
|
531
|
+
<NumberField
|
|
532
|
+
value={params.spread}
|
|
533
|
+
min={0}
|
|
534
|
+
step={0.05}
|
|
535
|
+
onChange={(spread) => binding.apply((s) => (s.emitterShape = createShape(type, {...params, spread})))}
|
|
536
|
+
/>
|
|
537
|
+
</Row>
|
|
538
|
+
</>
|
|
539
|
+
)}
|
|
540
|
+
</ModuleSection>
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function SizeOverLifeModule({binding}: ModuleProps) {
|
|
545
|
+
const system = binding.system;
|
|
546
|
+
const behavior = findBehavior<SizeOverLife>(system.behaviors, 'SizeOverLife');
|
|
547
|
+
const curve = behavior ? readScalar(behavior.size as never).curve : ([0.2, 1, 1, 0.4] as [number, number, number, number]);
|
|
548
|
+
return (
|
|
549
|
+
<ModuleSection
|
|
550
|
+
title="Size over Lifetime"
|
|
551
|
+
enabled={!!behavior}
|
|
552
|
+
onToggle={(enabled) =>
|
|
553
|
+
binding.apply((s) => {
|
|
554
|
+
removeBehavior(s, 'SizeOverLife');
|
|
555
|
+
if (enabled) {
|
|
556
|
+
s.addBehavior(new SizeOverLife(buildScalar({mode: 'curve', value: 1, min: 0, max: 1, curve}) as never));
|
|
557
|
+
}
|
|
558
|
+
})
|
|
559
|
+
}
|
|
560
|
+
>
|
|
561
|
+
<div style={{marginTop: 6}}>
|
|
562
|
+
<CurveEditor
|
|
563
|
+
pieces={behavior ? readPieces(behavior.size as never) : [{start: 0, p: curve}]}
|
|
564
|
+
maxValue={2}
|
|
565
|
+
onChange={(pieces) =>
|
|
566
|
+
binding.apply((s) => {
|
|
567
|
+
removeBehavior(s, 'SizeOverLife');
|
|
568
|
+
s.addBehavior(new SizeOverLife(buildCurve(pieces) as never));
|
|
569
|
+
})
|
|
570
|
+
}
|
|
571
|
+
/>
|
|
572
|
+
</div>
|
|
573
|
+
</ModuleSection>
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export function ColorOverLifeModule({binding}: ModuleProps) {
|
|
578
|
+
const system = binding.system;
|
|
579
|
+
const behavior = findBehavior<ColorOverLife>(system.behaviors, 'ColorOverLife');
|
|
580
|
+
const stops: GradientStop[] =
|
|
581
|
+
behavior && behavior.color instanceof Gradient ? readGradientStops(behavior.color) : DEFAULT_GRADIENT_STOPS;
|
|
582
|
+
const write = (nextStops: GradientStop[]) =>
|
|
583
|
+
binding.apply((s) => {
|
|
584
|
+
removeBehavior(s, 'ColorOverLife');
|
|
585
|
+
s.addBehavior(new ColorOverLife(buildGradient(nextStops)));
|
|
586
|
+
});
|
|
587
|
+
return (
|
|
588
|
+
<ModuleSection
|
|
589
|
+
title="Color over Lifetime"
|
|
590
|
+
enabled={!!behavior}
|
|
591
|
+
onToggle={(enabled) =>
|
|
592
|
+
binding.apply((s) => {
|
|
593
|
+
removeBehavior(s, 'ColorOverLife');
|
|
594
|
+
if (enabled) {
|
|
595
|
+
s.addBehavior(new ColorOverLife(buildGradient(stops)));
|
|
596
|
+
}
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
>
|
|
600
|
+
<div style={{marginTop: 6}}>
|
|
601
|
+
<GradientEditor stops={stops} onChange={write} />
|
|
602
|
+
</div>
|
|
603
|
+
</ModuleSection>
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export interface TextureOption {
|
|
608
|
+
label: string;
|
|
609
|
+
url: string;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* A host-supplied mesh for the Mesh render mode (e.g. asset meshes from BabylonJS Editor),
|
|
614
|
+
* analogous to TextureOption. The host provides ready vertex buffers; selecting one copies
|
|
615
|
+
* them into the system's instancing geometry.
|
|
616
|
+
*/
|
|
617
|
+
export interface GeometryOption {
|
|
618
|
+
label: string;
|
|
619
|
+
positions: Float32Array | number[];
|
|
620
|
+
indices?: Uint32Array | number[];
|
|
621
|
+
uvs?: Float32Array | number[];
|
|
622
|
+
normals?: Float32Array | number[];
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Vertex buffers a host loader (e.g. a GLB importer) returns for the Mesh render mode. */
|
|
626
|
+
export type GeometryData = Omit<GeometryOption, 'label'>;
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Tiny dependency-free wireframe thumbnail of a mesh geometry. Projects positions with a fixed
|
|
630
|
+
* isometric rotation, scales them to fit, and strokes the triangle edges on a 2D canvas — enough
|
|
631
|
+
* for the user to recognise which mesh they picked without spinning up a second Babylon scene.
|
|
632
|
+
*/
|
|
633
|
+
function MeshPreview({positions, indices, size = 96}: {positions: Float32Array; indices?: ArrayLike<number>; size?: number}) {
|
|
634
|
+
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
|
635
|
+
React.useEffect(() => {
|
|
636
|
+
const canvas = canvasRef.current;
|
|
637
|
+
if (!canvas || positions.length < 9) {
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const ctx = canvas.getContext('2d');
|
|
641
|
+
if (!ctx) {
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
const n = positions.length / 3;
|
|
645
|
+
// Fixed iso rotation (yaw ~35°, pitch ~30°) so depth reads clearly.
|
|
646
|
+
const cy = Math.cos(0.6), sy = Math.sin(0.6), cx = Math.cos(0.52), sx = Math.sin(0.52);
|
|
647
|
+
const pts: Array<[number, number]> = [];
|
|
648
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
649
|
+
for (let i = 0; i < n; i++) {
|
|
650
|
+
const x = positions[i * 3], y = positions[i * 3 + 1], z = positions[i * 3 + 2];
|
|
651
|
+
const rx = x * cy - z * sy;
|
|
652
|
+
const rz = x * sy + z * cy;
|
|
653
|
+
const ry = y * cx - rz * sx;
|
|
654
|
+
pts.push([rx, ry]);
|
|
655
|
+
if (rx < minX) minX = rx;
|
|
656
|
+
if (rx > maxX) maxX = rx;
|
|
657
|
+
if (ry < minY) minY = ry;
|
|
658
|
+
if (ry > maxY) maxY = ry;
|
|
659
|
+
}
|
|
660
|
+
const pad = 8;
|
|
661
|
+
const span = Math.max(maxX - minX, maxY - minY, 1e-6);
|
|
662
|
+
const scale = (size - pad * 2) / span;
|
|
663
|
+
const ox = pad + (size - pad * 2 - (maxX - minX) * scale) / 2;
|
|
664
|
+
const oy = pad + (size - pad * 2 - (maxY - minY) * scale) / 2;
|
|
665
|
+
const px = (i: number) => ox + (pts[i][0] - minX) * scale;
|
|
666
|
+
// Flip Y so +Y points up on screen.
|
|
667
|
+
const py = (i: number) => size - (oy + (pts[i][1] - minY) * scale);
|
|
668
|
+
|
|
669
|
+
ctx.clearRect(0, 0, size, size);
|
|
670
|
+
ctx.strokeStyle = 'rgba(158,185,255,0.85)';
|
|
671
|
+
ctx.lineWidth = 1;
|
|
672
|
+
const edge = (a: number, b: number) => {
|
|
673
|
+
ctx.beginPath();
|
|
674
|
+
ctx.moveTo(px(a), py(a));
|
|
675
|
+
ctx.lineTo(px(b), py(b));
|
|
676
|
+
ctx.stroke();
|
|
677
|
+
};
|
|
678
|
+
const tris = indices && indices.length ? indices.length : n;
|
|
679
|
+
for (let i = 0; i + 2 < tris; i += 3) {
|
|
680
|
+
const a = indices && indices.length ? indices[i] : i;
|
|
681
|
+
const b = indices && indices.length ? indices[i + 1] : i + 1;
|
|
682
|
+
const c = indices && indices.length ? indices[i + 2] : i + 2;
|
|
683
|
+
edge(a, b);
|
|
684
|
+
edge(b, c);
|
|
685
|
+
edge(c, a);
|
|
686
|
+
}
|
|
687
|
+
}, [positions, indices, size]);
|
|
688
|
+
return (
|
|
689
|
+
<canvas
|
|
690
|
+
ref={canvasRef}
|
|
691
|
+
width={size}
|
|
692
|
+
height={size}
|
|
693
|
+
style={{width: size, height: size, border: '1px solid #2b3761', borderRadius: 6, background: '#0c1330'}}
|
|
694
|
+
/>
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export function RendererModule({
|
|
699
|
+
binding,
|
|
700
|
+
textureOptions,
|
|
701
|
+
geometryOptions,
|
|
702
|
+
resolveTexture,
|
|
703
|
+
resolveGeometry,
|
|
704
|
+
}: ModuleProps & {
|
|
705
|
+
textureOptions?: TextureOption[];
|
|
706
|
+
geometryOptions?: GeometryOption[];
|
|
707
|
+
resolveTexture?: (url: string) => unknown;
|
|
708
|
+
resolveGeometry?: (file: File) => Promise<GeometryData>;
|
|
709
|
+
}) {
|
|
710
|
+
const system = binding.system;
|
|
711
|
+
const currentTextureUrl = (system.texture as {url?: string} | null)?.url ?? '';
|
|
712
|
+
const [promptingTextureUrl, setPromptingTextureUrl] = useState(false);
|
|
713
|
+
return (
|
|
714
|
+
<ModuleSection title="Renderer">
|
|
715
|
+
<Row label="Render mode">
|
|
716
|
+
<SelectField
|
|
717
|
+
value={system.renderMode}
|
|
718
|
+
options={[
|
|
719
|
+
{value: RenderMode.BillBoard, label: 'Billboard'},
|
|
720
|
+
{value: RenderMode.StretchedBillBoard, label: 'Stretched billboard'},
|
|
721
|
+
{value: RenderMode.HorizontalBillBoard, label: 'Horizontal billboard'},
|
|
722
|
+
{value: RenderMode.VerticalBillBoard, label: 'Vertical billboard'},
|
|
723
|
+
{value: RenderMode.Trail, label: 'Trail'},
|
|
724
|
+
{value: RenderMode.Mesh, label: 'Mesh'},
|
|
725
|
+
]}
|
|
726
|
+
onChange={(mode) => binding.apply((s) => (s.renderMode = mode))}
|
|
727
|
+
/>
|
|
728
|
+
</Row>
|
|
729
|
+
{system.renderMode === RenderMode.Trail && (
|
|
730
|
+
<>
|
|
731
|
+
<Row label="Trail length">
|
|
732
|
+
<NumberField
|
|
733
|
+
value={readScalar((system.rendererEmitterSettings as {startLength?: never}).startLength).value}
|
|
734
|
+
min={1}
|
|
735
|
+
step={1}
|
|
736
|
+
onChange={(v) =>
|
|
737
|
+
binding.apply((s) => {
|
|
738
|
+
(s.rendererEmitterSettings as {startLength?: unknown}).startLength = new ConstantValue(Math.round(v));
|
|
739
|
+
})
|
|
740
|
+
}
|
|
741
|
+
/>
|
|
742
|
+
</Row>
|
|
743
|
+
<Row label="Follow origin">
|
|
744
|
+
<CheckboxField
|
|
745
|
+
value={!!(system.rendererEmitterSettings as {followLocalOrigin?: boolean}).followLocalOrigin}
|
|
746
|
+
onChange={(v) =>
|
|
747
|
+
binding.apply((s) => {
|
|
748
|
+
(s.rendererEmitterSettings as {followLocalOrigin?: boolean}).followLocalOrigin = v;
|
|
749
|
+
})
|
|
750
|
+
}
|
|
751
|
+
/>
|
|
752
|
+
</Row>
|
|
753
|
+
</>
|
|
754
|
+
)}
|
|
755
|
+
{system.renderMode === RenderMode.StretchedBillBoard && (
|
|
756
|
+
<>
|
|
757
|
+
<Row label="Speed factor">
|
|
758
|
+
<NumberField
|
|
759
|
+
value={(system.rendererEmitterSettings as {speedFactor?: number}).speedFactor ?? 0}
|
|
760
|
+
step={0.1}
|
|
761
|
+
onChange={(v) => binding.apply((s) => ((s.rendererEmitterSettings as {speedFactor?: number}).speedFactor = v))}
|
|
762
|
+
/>
|
|
763
|
+
</Row>
|
|
764
|
+
<Row label="Length factor">
|
|
765
|
+
<NumberField
|
|
766
|
+
value={(system.rendererEmitterSettings as {lengthFactor?: number}).lengthFactor ?? 2}
|
|
767
|
+
step={0.1}
|
|
768
|
+
onChange={(v) => binding.apply((s) => ((s.rendererEmitterSettings as {lengthFactor?: number}).lengthFactor = v))}
|
|
769
|
+
/>
|
|
770
|
+
</Row>
|
|
771
|
+
</>
|
|
772
|
+
)}
|
|
773
|
+
{system.renderMode === RenderMode.Mesh &&
|
|
774
|
+
(() => {
|
|
775
|
+
const preset = detectGeometryPreset(system.instancingGeometry);
|
|
776
|
+
const vertexCount = system.instancingGeometry.length / 3;
|
|
777
|
+
const triangleCount = (system.getRendererSettings().instancingIndices?.length ?? 0) / 3;
|
|
778
|
+
const hostGeometry = geometryOptions ?? [];
|
|
779
|
+
const applyGeometry = (g: GeometryData) =>
|
|
780
|
+
binding.apply((s) => {
|
|
781
|
+
const settings = s.getRendererSettings();
|
|
782
|
+
settings.instancingIndices = (g.indices ? new Uint32Array(g.indices) : undefined) as never;
|
|
783
|
+
settings.instancingUVs = (g.uvs ? new Float32Array(g.uvs) : undefined) as never;
|
|
784
|
+
settings.instancingNormals = (g.normals ? new Float32Array(g.normals) : undefined) as never;
|
|
785
|
+
s.instancingGeometry = new Float32Array(g.positions);
|
|
786
|
+
});
|
|
787
|
+
return (
|
|
788
|
+
<Row label="Geometry">
|
|
789
|
+
<div style={{display: 'flex', flexDirection: 'column', gap: 6}}>
|
|
790
|
+
<SelectField<string>
|
|
791
|
+
value={preset}
|
|
792
|
+
options={[
|
|
793
|
+
...(preset === 'custom' ? [{value: 'custom', label: 'Custom (imported)'}] : []),
|
|
794
|
+
{value: 'quad', label: 'Quad'},
|
|
795
|
+
{value: 'cube', label: 'Cube'},
|
|
796
|
+
...hostGeometry.map((g, i) => ({value: `host:${i}`, label: g.label})),
|
|
797
|
+
...(resolveGeometry ? [{value: '__file', label: 'Load from file…'}] : []),
|
|
798
|
+
]}
|
|
799
|
+
onChange={(next) => {
|
|
800
|
+
if (next === 'custom') {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (next === '__file') {
|
|
804
|
+
const input = document.createElement('input');
|
|
805
|
+
input.type = 'file';
|
|
806
|
+
input.accept = '.glb,.gltf,model/gltf-binary,model/gltf+json';
|
|
807
|
+
input.onchange = () => {
|
|
808
|
+
const file = input.files?.[0];
|
|
809
|
+
if (file) {
|
|
810
|
+
resolveGeometry!(file).then(applyGeometry);
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
input.click();
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (next.startsWith('host:')) {
|
|
817
|
+
applyGeometry(hostGeometry[Number(next.slice(5))]);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
binding.apply((s) => {
|
|
821
|
+
const settings = s.getRendererSettings();
|
|
822
|
+
if (next === 'cube') {
|
|
823
|
+
settings.instancingIndices = MESH_CUBE_INDICES;
|
|
824
|
+
settings.instancingUVs = undefined;
|
|
825
|
+
settings.instancingNormals = undefined;
|
|
826
|
+
s.instancingGeometry = MESH_CUBE_POSITIONS;
|
|
827
|
+
} else {
|
|
828
|
+
settings.instancingIndices = MESH_QUAD_INDICES;
|
|
829
|
+
settings.instancingUVs = MESH_QUAD_UVS;
|
|
830
|
+
s.instancingGeometry = MESH_QUAD_POSITIONS;
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
}}
|
|
834
|
+
/>
|
|
835
|
+
<MeshPreview
|
|
836
|
+
positions={system.instancingGeometry}
|
|
837
|
+
indices={system.getRendererSettings().instancingIndices}
|
|
838
|
+
/>
|
|
839
|
+
{preset === 'custom' && (
|
|
840
|
+
<span style={{fontSize: 10.5, color: '#9eb9ff'}}>
|
|
841
|
+
{vertexCount} verts / {triangleCount} tris — picking a preset replaces this mesh
|
|
842
|
+
</span>
|
|
843
|
+
)}
|
|
844
|
+
</div>
|
|
845
|
+
</Row>
|
|
846
|
+
);
|
|
847
|
+
})()}
|
|
848
|
+
<div
|
|
849
|
+
style={{
|
|
850
|
+
marginTop: 10,
|
|
851
|
+
marginBottom: 2,
|
|
852
|
+
fontSize: 11,
|
|
853
|
+
fontWeight: 600,
|
|
854
|
+
color: '#7c8db5',
|
|
855
|
+
textTransform: 'uppercase',
|
|
856
|
+
letterSpacing: 0.4,
|
|
857
|
+
}}
|
|
858
|
+
>
|
|
859
|
+
Material
|
|
860
|
+
</div>
|
|
861
|
+
{system.material != null && (
|
|
862
|
+
<div style={{fontSize: 10.5, color: '#9eb9ff', marginBottom: 2}}>
|
|
863
|
+
Imported material: {(system.material as {name?: string}).name ?? 'unnamed'} — settings below were derived from it
|
|
864
|
+
</div>
|
|
865
|
+
)}
|
|
866
|
+
<Row label="Blend mode">
|
|
867
|
+
<SelectField
|
|
868
|
+
value={system.blending}
|
|
869
|
+
options={[
|
|
870
|
+
{value: 1, label: 'Additive'},
|
|
871
|
+
{value: 2, label: 'Alpha blend'},
|
|
872
|
+
{value: 3, label: 'Subtract'},
|
|
873
|
+
{value: 4, label: 'Multiply'},
|
|
874
|
+
]}
|
|
875
|
+
onChange={(blend) => binding.apply((s) => (s.blending = blend))}
|
|
876
|
+
/>
|
|
877
|
+
</Row>
|
|
878
|
+
<Row label="Transparent">
|
|
879
|
+
<CheckboxField
|
|
880
|
+
value={!!system.getRendererSettings().materialTransparent}
|
|
881
|
+
onChange={(v) =>
|
|
882
|
+
binding.apply((s) => {
|
|
883
|
+
s.getRendererSettings().materialTransparent = v;
|
|
884
|
+
s.neededToUpdateRender = true;
|
|
885
|
+
})
|
|
886
|
+
}
|
|
887
|
+
/>
|
|
888
|
+
</Row>
|
|
889
|
+
{resolveTexture && (
|
|
890
|
+
<Row label="Texture">
|
|
891
|
+
<SelectField
|
|
892
|
+
value={
|
|
893
|
+
!currentTextureUrl
|
|
894
|
+
? '__none'
|
|
895
|
+
: (textureOptions ?? []).some((o) => o.url === currentTextureUrl)
|
|
896
|
+
? currentTextureUrl
|
|
897
|
+
: '__current'
|
|
898
|
+
}
|
|
899
|
+
options={[
|
|
900
|
+
{value: '__none', label: '(none)'},
|
|
901
|
+
...(currentTextureUrl && !(textureOptions ?? []).some((o) => o.url === currentTextureUrl)
|
|
902
|
+
? [{value: '__current', label: `(current) ${currentTextureUrl.slice(-24)}`}]
|
|
903
|
+
: []),
|
|
904
|
+
...(textureOptions ?? []).map((o) => ({value: o.url, label: o.label})),
|
|
905
|
+
{value: '__url', label: 'Custom URL…'},
|
|
906
|
+
{value: '__file', label: 'Load from file…'},
|
|
907
|
+
]}
|
|
908
|
+
onChange={(url) => {
|
|
909
|
+
if (url === '__current') {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (url === '__none') {
|
|
913
|
+
binding.apply((s) => (s.texture = null as never));
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
if (url === '__url') {
|
|
917
|
+
setPromptingTextureUrl(true);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
if (url === '__file') {
|
|
921
|
+
const input = document.createElement('input');
|
|
922
|
+
input.type = 'file';
|
|
923
|
+
input.accept = 'image/*';
|
|
924
|
+
input.onchange = () => {
|
|
925
|
+
const file = input.files?.[0];
|
|
926
|
+
if (!file) {
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
// Read as a data URI so the texture survives Export/Save/reimport;
|
|
930
|
+
// an object URL (blob:) would be dead after a reload.
|
|
931
|
+
const reader = new FileReader();
|
|
932
|
+
reader.onload = () => {
|
|
933
|
+
binding.apply((s) => (s.texture = resolveTexture(reader.result as string) as never));
|
|
934
|
+
};
|
|
935
|
+
reader.readAsDataURL(file);
|
|
936
|
+
};
|
|
937
|
+
input.click();
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
binding.apply((s) => (s.texture = resolveTexture(url) as never));
|
|
941
|
+
}}
|
|
942
|
+
/>
|
|
943
|
+
{promptingTextureUrl && (
|
|
944
|
+
<PromptDialog
|
|
945
|
+
title="Texture URL"
|
|
946
|
+
defaultValue={currentTextureUrl}
|
|
947
|
+
placeholder="https://…"
|
|
948
|
+
onSubmit={(url) => {
|
|
949
|
+
binding.apply((s) => (s.texture = resolveTexture(url) as never));
|
|
950
|
+
setPromptingTextureUrl(false);
|
|
951
|
+
}}
|
|
952
|
+
onCancel={() => setPromptingTextureUrl(false)}
|
|
953
|
+
/>
|
|
954
|
+
)}
|
|
955
|
+
{currentTextureUrl && (
|
|
956
|
+
<img
|
|
957
|
+
src={currentTextureUrl}
|
|
958
|
+
alt="texture preview"
|
|
959
|
+
style={{
|
|
960
|
+
width: 64,
|
|
961
|
+
height: 64,
|
|
962
|
+
marginTop: 6,
|
|
963
|
+
objectFit: 'contain',
|
|
964
|
+
borderRadius: 6,
|
|
965
|
+
border: '1px solid #2b3761',
|
|
966
|
+
// Checkerboard so texture transparency is visible.
|
|
967
|
+
background:
|
|
968
|
+
'repeating-conic-gradient(#2a3252 0% 25%, #171d33 0% 50%) 0 0 / 12px 12px',
|
|
969
|
+
}}
|
|
970
|
+
/>
|
|
971
|
+
)}
|
|
972
|
+
</Row>
|
|
973
|
+
)}
|
|
974
|
+
<Row label="Render order">
|
|
975
|
+
<NumberField value={system.renderOrder} step={1} onChange={(v) => binding.apply((s) => (s.renderOrder = Math.round(v)))} />
|
|
976
|
+
</Row>
|
|
977
|
+
<Row label="Soft particles">
|
|
978
|
+
<CheckboxField value={system.softParticles} onChange={(v) => binding.apply((s) => (s.softParticles = v))} />
|
|
979
|
+
</Row>
|
|
980
|
+
{system.softParticles && (
|
|
981
|
+
<Row label="Fade near/far">
|
|
982
|
+
<div style={{display: 'flex', gap: 6}}>
|
|
983
|
+
<NumberField value={system.softNearFade} min={0} onChange={(v) => binding.apply((s) => (s.softNearFade = v))} />
|
|
984
|
+
<NumberField value={system.softFarFade} min={0} onChange={(v) => binding.apply((s) => (s.softFarFade = v))} />
|
|
985
|
+
</div>
|
|
986
|
+
</Row>
|
|
987
|
+
)}
|
|
988
|
+
<Row label="Alpha test">
|
|
989
|
+
<NumberField
|
|
990
|
+
value={system.getRendererSettings().materialAlphaTest ?? 0}
|
|
991
|
+
min={0}
|
|
992
|
+
step={0.05}
|
|
993
|
+
onChange={(v) =>
|
|
994
|
+
binding.apply((s) => {
|
|
995
|
+
s.getRendererSettings().materialAlphaTest = Math.min(1, v);
|
|
996
|
+
s.neededToUpdateRender = true;
|
|
997
|
+
})
|
|
998
|
+
}
|
|
999
|
+
/>
|
|
1000
|
+
</Row>
|
|
1001
|
+
<Row label="Depth write">
|
|
1002
|
+
<CheckboxField
|
|
1003
|
+
value={system.getRendererSettings().materialDepthWrite}
|
|
1004
|
+
onChange={(v) =>
|
|
1005
|
+
binding.apply((s) => {
|
|
1006
|
+
s.getRendererSettings().materialDepthWrite = v;
|
|
1007
|
+
s.neededToUpdateRender = true;
|
|
1008
|
+
})
|
|
1009
|
+
}
|
|
1010
|
+
/>
|
|
1011
|
+
</Row>
|
|
1012
|
+
<Row label="Depth test">
|
|
1013
|
+
<CheckboxField
|
|
1014
|
+
value={system.getRendererSettings().materialDepthTest}
|
|
1015
|
+
onChange={(v) =>
|
|
1016
|
+
binding.apply((s) => {
|
|
1017
|
+
s.getRendererSettings().materialDepthTest = v;
|
|
1018
|
+
s.neededToUpdateRender = true;
|
|
1019
|
+
})
|
|
1020
|
+
}
|
|
1021
|
+
/>
|
|
1022
|
+
</Row>
|
|
1023
|
+
</ModuleSection>
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function removeBehavior(system: ParticleSystem, type: string): void {
|
|
1028
|
+
const behaviors = system.behaviors as Behavior[];
|
|
1029
|
+
for (let i = behaviors.length - 1; i >= 0; i--) {
|
|
1030
|
+
if ((behaviors[i] as {type?: string}).type === type) {
|
|
1031
|
+
behaviors.splice(i, 1);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|