threlte-vfx 0.0.10 → 0.2.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.
@@ -0,0 +1,171 @@
1
+ <script lang="ts">
2
+ import { T, useThrelte, useTask } from '@threlte/core'
3
+ import { onMount } from 'svelte'
4
+ import { Vector3, Quaternion, Group } from 'three/webgpu'
5
+ import {
6
+ EmitterController,
7
+ isWebGPUBackend,
8
+ coreStore,
9
+ type EmitterControllerOptions,
10
+ } from 'core-vfx'
11
+
12
+ // Reusable temp objects for transforms (avoid allocations in render loop)
13
+ const worldPos = new Vector3()
14
+ const worldQuat = new Quaternion()
15
+
16
+ let {
17
+ name = undefined,
18
+ particlesRef = undefined,
19
+ position = [0, 0, 0],
20
+ emitCount = 10,
21
+ delay = 0,
22
+ autoStart = true,
23
+ loop = true,
24
+ localDirection = false,
25
+ direction = undefined,
26
+ overrides = null,
27
+ onEmit = undefined,
28
+ children,
29
+ }: {
30
+ name?: string
31
+ particlesRef?: any
32
+ position?: [number, number, number]
33
+ emitCount?: number
34
+ delay?: number
35
+ autoStart?: boolean
36
+ loop?: boolean
37
+ localDirection?: boolean
38
+ direction?: EmitterControllerOptions['direction']
39
+ overrides?: Record<string, unknown> | null
40
+ onEmit?: EmitterControllerOptions['onEmit']
41
+ children?: import('svelte').Snippet
42
+ } = $props()
43
+
44
+ const { renderer } = useThrelte()
45
+
46
+ let groupRef: Group | null = $state(null)
47
+ let isWebGPU = $state(false)
48
+
49
+ const controller = new EmitterController({
50
+ emitCount,
51
+ delay,
52
+ autoStart,
53
+ loop,
54
+ localDirection,
55
+ direction,
56
+ overrides,
57
+ onEmit,
58
+ })
59
+
60
+ function getParticleSystem() {
61
+ if (particlesRef) {
62
+ return particlesRef.value || particlesRef
63
+ }
64
+ return name ? coreStore.getState().getParticles(name) : undefined
65
+ }
66
+
67
+ // Watch option changes
68
+ $effect(() => {
69
+ const _deps = [
70
+ emitCount,
71
+ delay,
72
+ autoStart,
73
+ loop,
74
+ localDirection,
75
+ direction,
76
+ overrides,
77
+ onEmit,
78
+ ]
79
+
80
+ controller.updateOptions({
81
+ emitCount,
82
+ delay,
83
+ autoStart,
84
+ loop,
85
+ localDirection,
86
+ direction,
87
+ overrides,
88
+ onEmit,
89
+ })
90
+ })
91
+
92
+ function checkWebGPU() {
93
+ if (renderer && isWebGPUBackend(renderer)) {
94
+ isWebGPU = true
95
+ const system = getParticleSystem()
96
+ if (system) controller.setSystem(system)
97
+ }
98
+ }
99
+
100
+ onMount(() => {
101
+ checkWebGPU()
102
+ })
103
+
104
+ // Frame loop
105
+ useTask((delta) => {
106
+ if (!isWebGPU) return
107
+
108
+ if (!controller.getSystem()) {
109
+ const system = getParticleSystem()
110
+ if (system) controller.setSystem(system)
111
+ }
112
+
113
+ if (!groupRef) return
114
+
115
+ groupRef.getWorldPosition(worldPos)
116
+ groupRef.getWorldQuaternion(worldQuat)
117
+ controller.update(delta, worldPos, worldQuat)
118
+ })
119
+
120
+ // Exposed API
121
+ export function emit(emitOverrides: Record<string, unknown> | null = null) {
122
+ if (!groupRef) return false
123
+
124
+ if (!controller.getSystem()) {
125
+ const system = getParticleSystem()
126
+ if (system) controller.setSystem(system)
127
+ }
128
+
129
+ if (!controller.getSystem()) {
130
+ if (name) {
131
+ console.warn(
132
+ `VFXEmitter: No particle system found for name "${name}"`
133
+ )
134
+ }
135
+ return false
136
+ }
137
+
138
+ groupRef.getWorldPosition(worldPos)
139
+ groupRef.getWorldQuaternion(worldQuat)
140
+ return controller.emitAtPosition(worldPos, worldQuat, emitOverrides)
141
+ }
142
+
143
+ export function burst(count: number) {
144
+ if (!groupRef) return false
145
+
146
+ if (!controller.getSystem()) {
147
+ const system = getParticleSystem()
148
+ if (system) controller.setSystem(system)
149
+ }
150
+
151
+ if (!controller.getSystem()) return false
152
+
153
+ groupRef.getWorldPosition(worldPos)
154
+ groupRef.getWorldQuaternion(worldQuat)
155
+ return controller.burst(count, worldPos, worldQuat)
156
+ }
157
+
158
+ export function start() {
159
+ controller.start()
160
+ }
161
+
162
+ export function stop() {
163
+ controller.stop()
164
+ }
165
+ </script>
166
+
167
+ <T.Group bind:ref={groupRef} position={position}>
168
+ {#if children}
169
+ {@render children()}
170
+ {/if}
171
+ </T.Group>
@@ -0,0 +1,773 @@
1
+ <script lang="ts">
2
+ import { T, useThrelte, useTask } from '@threlte/core'
3
+ import { onMount, onDestroy, untrack } from 'svelte'
4
+ import * as THREE from 'three/webgpu'
5
+ import { coreStore } from 'core-vfx'
6
+ import {
7
+ Appearance,
8
+ Blending,
9
+ EmitterShape,
10
+ Lighting,
11
+ VFXParticleSystem,
12
+ isWebGPUBackend,
13
+ isNonDefaultRotation,
14
+ normalizeProps,
15
+ updateUniforms,
16
+ updateUniformsPartial,
17
+ resolveFeatures,
18
+ type VFXParticleSystemOptions,
19
+ type TurbulenceConfig,
20
+ type AttractorConfig,
21
+ type CollisionConfig,
22
+ type FrictionConfig,
23
+ type FlipbookConfig,
24
+ type StretchConfig,
25
+ type Rotation3DInput,
26
+ } from 'core-vfx'
27
+
28
+ // Props
29
+ let {
30
+ name = undefined,
31
+ debug = false,
32
+ maxParticles = 10000,
33
+ size = [0.1, 0.3],
34
+ colorStart = ['#ffffff'],
35
+ colorEnd = null,
36
+ fadeSize = [1, 0],
37
+ fadeSizeCurve = null,
38
+ fadeOpacity = [1, 0],
39
+ fadeOpacityCurve = null,
40
+ velocityCurve = null,
41
+ gravity = [0, 0, 0],
42
+ lifetime = [1, 2],
43
+ direction = [[-1, 1], [0, 1], [-1, 1]],
44
+ startPosition = [[0, 0], [0, 0], [0, 0]],
45
+ speed = [0.1, 0.1],
46
+ friction = { intensity: 0, easing: 'linear' },
47
+ appearance = Appearance.GRADIENT,
48
+ alphaMap = null,
49
+ flipbook = null,
50
+ rotation = [0, 0],
51
+ rotationSpeed = [0, 0],
52
+ rotationSpeedCurve = null,
53
+ geometry = null,
54
+ orientToDirection = false,
55
+ orientAxis = 'z',
56
+ stretchBySpeed = null,
57
+ lighting = Lighting.STANDARD,
58
+ shadow = false,
59
+ blending = Blending.NORMAL,
60
+ intensity = 1,
61
+ position = [0, 0, 0],
62
+ autoStart = true,
63
+ delay = 0,
64
+ backdropNode = null,
65
+ opacityNode = null,
66
+ colorNode = null,
67
+ alphaTestNode = null,
68
+ castShadowNode = null,
69
+ emitCount = 1,
70
+ emitterShape = EmitterShape.BOX,
71
+ emitterRadius = [0, 1],
72
+ emitterAngle = Math.PI / 4,
73
+ emitterHeight = [0, 1],
74
+ emitterSurfaceOnly = false,
75
+ emitterDirection = [0, 1, 0],
76
+ turbulence = null,
77
+ attractors = null,
78
+ attractToCenter = false,
79
+ startPositionAsDirection = false,
80
+ softParticles = false,
81
+ softDistance = 0.5,
82
+ collision = null,
83
+ curveTexturePath = null,
84
+ depthTest = true,
85
+ renderOrder = 0,
86
+ }: {
87
+ name?: string
88
+ debug?: boolean
89
+ maxParticles?: number
90
+ size?: [number, number] | number
91
+ colorStart?: string[]
92
+ colorEnd?: string[] | null
93
+ fadeSize?: [number, number]
94
+ fadeSizeCurve?: unknown[] | null
95
+ fadeOpacity?: [number, number]
96
+ fadeOpacityCurve?: unknown[] | null
97
+ velocityCurve?: unknown[] | null
98
+ gravity?: [number, number, number]
99
+ lifetime?: [number, number]
100
+ direction?: VFXParticleSystemOptions['direction']
101
+ startPosition?: VFXParticleSystemOptions['startPosition']
102
+ speed?: [number, number] | number
103
+ friction?: FrictionConfig
104
+ appearance?: string | number
105
+ alphaMap?: THREE.Texture | null
106
+ flipbook?: FlipbookConfig | null
107
+ rotation?: Rotation3DInput
108
+ rotationSpeed?: Rotation3DInput
109
+ rotationSpeedCurve?: unknown[] | null
110
+ geometry?: THREE.BufferGeometry | null
111
+ orientToDirection?: boolean
112
+ orientAxis?: string
113
+ stretchBySpeed?: StretchConfig | null
114
+ lighting?: string | number
115
+ shadow?: boolean
116
+ blending?: string | number
117
+ intensity?: number
118
+ position?: [number, number, number]
119
+ autoStart?: boolean
120
+ delay?: number
121
+ backdropNode?: unknown
122
+ opacityNode?: unknown
123
+ colorNode?: unknown
124
+ alphaTestNode?: unknown
125
+ castShadowNode?: unknown
126
+ emitCount?: number
127
+ emitterShape?: string | number
128
+ emitterRadius?: [number, number]
129
+ emitterAngle?: number
130
+ emitterHeight?: [number, number]
131
+ emitterSurfaceOnly?: boolean
132
+ emitterDirection?: [number, number, number]
133
+ turbulence?: TurbulenceConfig | null
134
+ attractors?: AttractorConfig[] | null
135
+ attractToCenter?: boolean
136
+ startPositionAsDirection?: boolean
137
+ softParticles?: boolean
138
+ softDistance?: number
139
+ collision?: CollisionConfig | null
140
+ curveTexturePath?: string | null
141
+ depthTest?: boolean
142
+ renderOrder?: number
143
+ } = $props()
144
+
145
+ const { renderer } = useThrelte()
146
+
147
+ let warnedWebGL = false
148
+ let mounted = false
149
+
150
+ // Internal state — NOT tracked by effects (plain variables)
151
+ let _system: VFXParticleSystem | null = null
152
+ let _renderObject: THREE.Object3D | null = null
153
+ let _isWebGPU = false
154
+ let _emitting = autoStart
155
+
156
+ // Reactive state for the template only
157
+ let renderObjectForTemplate: THREE.Object3D | null = $state(null)
158
+ let isWebGPUForTemplate = $state(false)
159
+
160
+ let debugValues: Record<string, unknown> | null = null
161
+
162
+ // Track structural props for recreation
163
+ let activeMaxParticles = $state(maxParticles)
164
+ let activeLighting: string | number = $state(lighting)
165
+ let activeAppearance: string | number = $state(appearance)
166
+ let activeOrientToDirection = $state(orientToDirection)
167
+ let activeGeometry: THREE.BufferGeometry | null = $state(geometry)
168
+ let activeShadow = $state(shadow)
169
+ let activeFadeSizeCurve: unknown[] | null = $state(fadeSizeCurve)
170
+ let activeFadeOpacityCurve: unknown[] | null = $state(fadeOpacityCurve)
171
+ let activeVelocityCurve: unknown[] | null = $state(velocityCurve)
172
+ let activeRotationSpeedCurve: unknown[] | null = $state(rotationSpeedCurve)
173
+ let activeTurbulence = $state(
174
+ turbulence !== null && (turbulence?.intensity ?? 0) > 0
175
+ )
176
+ let activeAttractors = $state(
177
+ attractors !== null && (attractors?.length ?? 0) > 0
178
+ )
179
+ let activeCollision = $state(collision !== null)
180
+ let activeNeedsPerParticleColor = $state(
181
+ colorStart.length > 1 || colorEnd !== null
182
+ )
183
+ let activeNeedsRotation = $state(
184
+ isNonDefaultRotation(rotation) || isNonDefaultRotation(rotationSpeed)
185
+ )
186
+
187
+ // Debug panel refs
188
+ let prevGeometryType: unknown = null
189
+ let prevGeometryArgs: unknown = null
190
+
191
+ function buildOptions(): VFXParticleSystemOptions {
192
+ const dbg = debug ? debugValues : null
193
+ return {
194
+ maxParticles: untrack(() => activeMaxParticles) as number,
195
+ size: (dbg?.size ?? size) as VFXParticleSystemOptions['size'],
196
+ colorStart: (dbg?.colorStart ?? colorStart) as string[],
197
+ colorEnd:
198
+ dbg?.colorEnd !== undefined
199
+ ? (dbg.colorEnd as string[] | null)
200
+ : colorEnd,
201
+ fadeSize: (dbg?.fadeSize ?? fadeSize) as [number, number],
202
+ fadeSizeCurve: untrack(() => activeFadeSizeCurve) as VFXParticleSystemOptions['fadeSizeCurve'],
203
+ fadeOpacity: (dbg?.fadeOpacity ?? fadeOpacity) as [number, number],
204
+ fadeOpacityCurve: untrack(() => activeFadeOpacityCurve) as VFXParticleSystemOptions['fadeOpacityCurve'],
205
+ velocityCurve: untrack(() => activeVelocityCurve) as VFXParticleSystemOptions['velocityCurve'],
206
+ gravity: (dbg?.gravity ?? gravity) as [number, number, number],
207
+ lifetime: (dbg?.lifetime ?? lifetime) as [number, number],
208
+ direction: (dbg?.direction ?? direction) as VFXParticleSystemOptions['direction'],
209
+ startPosition: (dbg?.startPosition ?? startPosition) as VFXParticleSystemOptions['startPosition'],
210
+ speed: (dbg?.speed ?? speed) as VFXParticleSystemOptions['speed'],
211
+ friction: (dbg?.friction ?? friction) as FrictionConfig,
212
+ appearance: untrack(() => activeAppearance) as VFXParticleSystemOptions['appearance'],
213
+ alphaMap,
214
+ flipbook,
215
+ rotation: (dbg?.rotation ?? rotation) as Rotation3DInput,
216
+ rotationSpeed: (dbg?.rotationSpeed ?? rotationSpeed) as Rotation3DInput,
217
+ rotationSpeedCurve: untrack(() => activeRotationSpeedCurve) as VFXParticleSystemOptions['rotationSpeedCurve'],
218
+ geometry: untrack(() => activeGeometry),
219
+ orientToDirection: untrack(() => activeOrientToDirection) as boolean,
220
+ orientAxis: (dbg?.orientAxis ?? orientAxis) as string,
221
+ stretchBySpeed: (dbg?.stretchBySpeed ?? stretchBySpeed) as StretchConfig | null,
222
+ lighting: untrack(() => activeLighting) as VFXParticleSystemOptions['lighting'],
223
+ shadow: untrack(() => activeShadow) as boolean,
224
+ blending: (dbg?.blending ?? blending) as VFXParticleSystemOptions['blending'],
225
+ intensity: (dbg?.intensity ?? intensity) as number,
226
+ position: (dbg?.position ?? position) as [number, number, number],
227
+ autoStart: (dbg?.autoStart ?? autoStart) as boolean,
228
+ delay: (dbg?.delay ?? delay) as number,
229
+ emitCount: (dbg?.emitCount ?? emitCount) as number,
230
+ emitterShape: (dbg?.emitterShape ?? emitterShape) as VFXParticleSystemOptions['emitterShape'],
231
+ emitterRadius: (dbg?.emitterRadius ?? emitterRadius) as [number, number],
232
+ emitterAngle: (dbg?.emitterAngle ?? emitterAngle) as number,
233
+ emitterHeight: (dbg?.emitterHeight ?? emitterHeight) as [number, number],
234
+ emitterSurfaceOnly: (dbg?.emitterSurfaceOnly ?? emitterSurfaceOnly) as boolean,
235
+ emitterDirection: (dbg?.emitterDirection ?? emitterDirection) as [number, number, number],
236
+ turbulence: (dbg?.turbulence ?? turbulence) as TurbulenceConfig | null,
237
+ attractors: (dbg?.attractors ?? attractors) as AttractorConfig[] | null,
238
+ attractToCenter: (dbg?.attractToCenter ?? attractToCenter) as boolean,
239
+ startPositionAsDirection: (dbg?.startPositionAsDirection ?? startPositionAsDirection) as boolean,
240
+ softParticles: (dbg?.softParticles ?? softParticles) as boolean,
241
+ softDistance: (dbg?.softDistance ?? softDistance) as number,
242
+ collision: (dbg?.collision ?? collision) as CollisionConfig | null,
243
+ backdropNode: backdropNode as VFXParticleSystemOptions['backdropNode'],
244
+ opacityNode: opacityNode as VFXParticleSystemOptions['opacityNode'],
245
+ colorNode: colorNode as VFXParticleSystemOptions['colorNode'],
246
+ alphaTestNode: alphaTestNode as VFXParticleSystemOptions['alphaTestNode'],
247
+ castShadowNode: castShadowNode as VFXParticleSystemOptions['castShadowNode'],
248
+ depthTest: (dbg?.depthTest ?? depthTest) as boolean,
249
+ renderOrder: (dbg?.renderOrder ?? renderOrder) as number,
250
+ curveTexturePath,
251
+ }
252
+ }
253
+
254
+ function createSystem() {
255
+ const r = renderer as unknown as THREE.WebGPURenderer
256
+ if (!r) return null
257
+ return new VFXParticleSystem(r, buildOptions())
258
+ }
259
+
260
+ function destroySystem() {
261
+ if (!_system) return
262
+ if (name) {
263
+ coreStore.getState().unregisterParticles(name)
264
+ }
265
+ _system.dispose()
266
+ _system = null
267
+ _renderObject = null
268
+ renderObjectForTemplate = null
269
+ isWebGPUForTemplate = false
270
+ }
271
+
272
+ function initSystem() {
273
+ const oldSystem = _system
274
+ if (oldSystem) {
275
+ oldSystem.initialized = false
276
+ if (name) {
277
+ coreStore.getState().unregisterParticles(name)
278
+ }
279
+ }
280
+ _system = null
281
+ _renderObject = null
282
+
283
+ if (!renderer) {
284
+ console.warn('threlte-vfx: No renderer instance available')
285
+ return
286
+ }
287
+
288
+ if (!isWebGPUBackend(renderer)) {
289
+ if (!warnedWebGL) {
290
+ warnedWebGL = true
291
+ console.warn(
292
+ 'threlte-vfx: WebGPU backend not detected. Particle system disabled.'
293
+ )
294
+ }
295
+ _isWebGPU = false
296
+ isWebGPUForTemplate = false
297
+ renderObjectForTemplate = null
298
+ return
299
+ }
300
+
301
+ _isWebGPU = true
302
+ const newSystem = createSystem()
303
+ if (!newSystem) return
304
+
305
+ _system = newSystem
306
+ _renderObject = newSystem.renderObject
307
+
308
+ newSystem.init()
309
+
310
+ if (name) {
311
+ coreStore.getState().registerParticles(name, {
312
+ spawn: (x = 0, y = 0, z = 0, count = 20, overrides = null) => {
313
+ const [px, py, pz] = newSystem.position
314
+ newSystem.spawn(px + x, py + y, pz + z, count, overrides)
315
+ },
316
+ start: () => {
317
+ newSystem.start()
318
+ _emitting = true
319
+ },
320
+ stop: () => {
321
+ newSystem.stop()
322
+ _emitting = false
323
+ },
324
+ get isEmitting() {
325
+ return _emitting
326
+ },
327
+ clear: () => newSystem.clear(),
328
+ uniforms: newSystem.uniforms,
329
+ })
330
+ }
331
+
332
+ if (debug) {
333
+ initDebugPanel()
334
+ }
335
+
336
+ // Update template-facing reactive state last
337
+ isWebGPUForTemplate = true
338
+ renderObjectForTemplate = newSystem.renderObject
339
+ }
340
+
341
+ // Debug panel support
342
+ function handleDebugUpdate(newValues: Record<string, unknown>) {
343
+ debugValues = { ...debugValues, ...newValues }
344
+ if (!_system) return
345
+
346
+ if ('colorStart' in newValues && newValues.colorStart) {
347
+ const currentColorEnd = debugValues?.colorEnd
348
+ if (!currentColorEnd) {
349
+ newValues = { ...newValues, colorEnd: null }
350
+ }
351
+ }
352
+ if ('colorEnd' in newValues && !newValues.colorEnd) {
353
+ newValues = {
354
+ ...newValues,
355
+ colorEnd: null,
356
+ colorStart:
357
+ newValues.colorStart ??
358
+ debugValues?.colorStart ?? ['#ffffff'],
359
+ }
360
+ }
361
+
362
+ updateUniformsPartial(_system.uniforms, newValues)
363
+
364
+ if ('fadeSizeCurve' in newValues) {
365
+ activeFadeSizeCurve = newValues.fadeSizeCurve as unknown[] | null
366
+ }
367
+ if ('fadeOpacityCurve' in newValues) {
368
+ activeFadeOpacityCurve = newValues.fadeOpacityCurve as unknown[] | null
369
+ }
370
+ if ('velocityCurve' in newValues) {
371
+ activeVelocityCurve = newValues.velocityCurve as unknown[] | null
372
+ }
373
+ if ('rotationSpeedCurve' in newValues) {
374
+ activeRotationSpeedCurve = newValues.rotationSpeedCurve as unknown[] | null
375
+ }
376
+
377
+ if ('turbulence' in newValues) {
378
+ _system.setTurbulenceSpeed(
379
+ (newValues.turbulence as TurbulenceConfig | null)?.speed ?? 1
380
+ )
381
+ }
382
+
383
+ const newFeatures = resolveFeatures(
384
+ debugValues as Record<string, unknown>
385
+ )
386
+ if (newFeatures.needsRotation !== activeNeedsRotation) {
387
+ activeNeedsRotation = newFeatures.needsRotation
388
+ }
389
+ if (newFeatures.needsPerParticleColor !== activeNeedsPerParticleColor) {
390
+ activeNeedsPerParticleColor = newFeatures.needsPerParticleColor
391
+ }
392
+ if (newFeatures.turbulence !== activeTurbulence) {
393
+ activeTurbulence = newFeatures.turbulence
394
+ }
395
+ if (newFeatures.attractors !== activeAttractors) {
396
+ activeAttractors = newFeatures.attractors
397
+ }
398
+ if (newFeatures.collision !== activeCollision) {
399
+ activeCollision = newFeatures.collision
400
+ }
401
+
402
+ if (newValues.position) {
403
+ _system.setPosition(newValues.position as [number, number, number])
404
+ }
405
+
406
+ if ('delay' in newValues) _system.setDelay((newValues.delay as number) ?? 0)
407
+ if ('emitCount' in newValues)
408
+ _system.setEmitCount((newValues.emitCount as number) ?? 1)
409
+
410
+ if (newValues.autoStart !== undefined) {
411
+ _emitting = newValues.autoStart as boolean
412
+ }
413
+
414
+ if (_system.material && newValues.blending !== undefined) {
415
+ ;(_system.material as any).blending = newValues.blending
416
+ ;(_system.material as any).needsUpdate = true
417
+ }
418
+
419
+ if (
420
+ newValues.maxParticles !== undefined &&
421
+ newValues.maxParticles !== activeMaxParticles
422
+ ) {
423
+ activeMaxParticles = newValues.maxParticles as number
424
+ _system.initialized = false
425
+ _system.nextIndex = 0
426
+ }
427
+ if (
428
+ newValues.lighting !== undefined &&
429
+ newValues.lighting !== activeLighting
430
+ ) {
431
+ activeLighting = newValues.lighting as string | number
432
+ }
433
+ if (
434
+ newValues.appearance !== undefined &&
435
+ newValues.appearance !== activeAppearance
436
+ ) {
437
+ activeAppearance = newValues.appearance as string | number
438
+ }
439
+ if (
440
+ newValues.orientToDirection !== undefined &&
441
+ newValues.orientToDirection !== activeOrientToDirection
442
+ ) {
443
+ activeOrientToDirection = newValues.orientToDirection as boolean
444
+ }
445
+ if (
446
+ newValues.shadow !== undefined &&
447
+ newValues.shadow !== activeShadow
448
+ ) {
449
+ activeShadow = newValues.shadow as boolean
450
+ }
451
+
452
+ if ('geometryType' in newValues || 'geometryArgs' in newValues) {
453
+ const geoType =
454
+ newValues.geometryType ?? prevGeometryType
455
+ const geoArgs =
456
+ newValues.geometryArgs ?? prevGeometryArgs
457
+ const geoTypeChanged =
458
+ 'geometryType' in newValues &&
459
+ geoType !== prevGeometryType
460
+ const geoArgsChanged =
461
+ 'geometryArgs' in newValues &&
462
+ JSON.stringify(geoArgs) !==
463
+ JSON.stringify(prevGeometryArgs)
464
+
465
+ if (geoTypeChanged || geoArgsChanged) {
466
+ prevGeometryType = geoType
467
+ prevGeometryArgs = geoArgs
468
+
469
+ import('debug-vfx').then((mod) => {
470
+ const { createGeometry, GeometryType } = mod
471
+ if (geoType === GeometryType.NONE || !geoType) {
472
+ if (activeGeometry !== null && !geometry) {
473
+ activeGeometry.dispose()
474
+ }
475
+ activeGeometry = null
476
+ } else {
477
+ const newGeometry = createGeometry(geoType as string, geoArgs as Record<string, number> | undefined)
478
+ if (newGeometry) {
479
+ if (
480
+ activeGeometry !== null &&
481
+ activeGeometry !== geometry
482
+ ) {
483
+ activeGeometry.dispose()
484
+ }
485
+ activeGeometry = newGeometry
486
+ }
487
+ }
488
+ })
489
+ }
490
+ }
491
+ }
492
+
493
+ function initDebugPanel() {
494
+ import('debug-vfx').then((mod) => {
495
+ const { renderDebugPanel, detectGeometryTypeAndArgs } = mod
496
+
497
+ if (!debugValues) {
498
+ const initialValues: Record<string, unknown> = {
499
+ name,
500
+ maxParticles,
501
+ size,
502
+ colorStart,
503
+ colorEnd,
504
+ fadeSize,
505
+ fadeSizeCurve: fadeSizeCurve || null,
506
+ fadeOpacity,
507
+ fadeOpacityCurve: fadeOpacityCurve || null,
508
+ velocityCurve: velocityCurve || null,
509
+ gravity,
510
+ lifetime,
511
+ direction,
512
+ startPosition,
513
+ startPositionAsDirection,
514
+ speed,
515
+ friction,
516
+ appearance,
517
+ rotation,
518
+ rotationSpeed,
519
+ rotationSpeedCurve: rotationSpeedCurve || null,
520
+ orientToDirection,
521
+ orientAxis,
522
+ stretchBySpeed: stretchBySpeed || null,
523
+ lighting,
524
+ shadow,
525
+ blending,
526
+ intensity,
527
+ position,
528
+ autoStart,
529
+ delay,
530
+ emitCount,
531
+ emitterShape,
532
+ emitterRadius,
533
+ emitterAngle,
534
+ emitterHeight,
535
+ emitterSurfaceOnly,
536
+ emitterDirection,
537
+ turbulence,
538
+ attractToCenter,
539
+ softParticles,
540
+ softDistance,
541
+ collision,
542
+ ...detectGeometryTypeAndArgs(geometry),
543
+ }
544
+
545
+ debugValues = initialValues
546
+ prevGeometryType = initialValues.geometryType
547
+ prevGeometryArgs = initialValues.geometryArgs
548
+ }
549
+
550
+ renderDebugPanel(debugValues, handleDebugUpdate, 'threlte')
551
+ })
552
+ }
553
+
554
+ // Watch structural props for recreation (skip in debug mode)
555
+ $effect(() => {
556
+ // Read all structural props to track them
557
+ const _deps = [
558
+ maxParticles,
559
+ lighting,
560
+ appearance,
561
+ orientToDirection,
562
+ geometry,
563
+ shadow,
564
+ fadeSizeCurve,
565
+ fadeOpacityCurve,
566
+ velocityCurve,
567
+ rotationSpeedCurve,
568
+ colorStart.length,
569
+ colorEnd,
570
+ rotation,
571
+ rotationSpeed,
572
+ turbulence,
573
+ attractors,
574
+ collision,
575
+ ]
576
+
577
+ if (debug) return
578
+
579
+ // Use untrack for writes to avoid circular dependencies
580
+ untrack(() => {
581
+ activeMaxParticles = maxParticles
582
+ activeLighting = lighting
583
+ activeAppearance = appearance
584
+ activeOrientToDirection = orientToDirection
585
+ activeGeometry = geometry
586
+ activeShadow = shadow
587
+ activeFadeSizeCurve = fadeSizeCurve
588
+ activeFadeOpacityCurve = fadeOpacityCurve
589
+ activeVelocityCurve = velocityCurve
590
+ activeRotationSpeedCurve = rotationSpeedCurve
591
+ activeNeedsPerParticleColor =
592
+ colorStart.length > 1 || colorEnd !== null
593
+ activeNeedsRotation =
594
+ isNonDefaultRotation(rotation) ||
595
+ isNonDefaultRotation(rotationSpeed)
596
+ activeTurbulence =
597
+ turbulence !== null && (turbulence?.intensity ?? 0) > 0
598
+ activeAttractors =
599
+ attractors !== null && (attractors?.length ?? 0) > 0
600
+ activeCollision = collision !== null
601
+ })
602
+ })
603
+
604
+ // Watch structural active values for system recreation
605
+ $effect(() => {
606
+ // Read all active values to track them
607
+ const _deps = [
608
+ activeMaxParticles,
609
+ activeLighting,
610
+ activeAppearance,
611
+ activeOrientToDirection,
612
+ activeGeometry,
613
+ activeShadow,
614
+ activeNeedsPerParticleColor,
615
+ activeNeedsRotation,
616
+ activeTurbulence,
617
+ activeAttractors,
618
+ activeCollision,
619
+ activeFadeSizeCurve,
620
+ activeFadeOpacityCurve,
621
+ activeVelocityCurve,
622
+ activeRotationSpeedCurve,
623
+ ]
624
+
625
+ if (!mounted) return
626
+
627
+ // Use untrack for the actual init to avoid reading/writing
628
+ // reactive state that would re-trigger this effect
629
+ untrack(() => {
630
+ if (!_isWebGPU) return
631
+ initSystem()
632
+ })
633
+ })
634
+
635
+ // Watch non-structural props for uniform updates (skip in debug mode)
636
+ $effect(() => {
637
+ const _deps = [
638
+ position,
639
+ size,
640
+ fadeSize,
641
+ fadeOpacity,
642
+ gravity,
643
+ friction,
644
+ speed,
645
+ lifetime,
646
+ direction,
647
+ rotation,
648
+ rotationSpeed,
649
+ intensity,
650
+ colorStart,
651
+ colorEnd,
652
+ collision,
653
+ emitterShape,
654
+ emitterRadius,
655
+ emitterAngle,
656
+ emitterHeight,
657
+ emitterSurfaceOnly,
658
+ emitterDirection,
659
+ turbulence,
660
+ startPosition,
661
+ attractors,
662
+ attractToCenter,
663
+ startPositionAsDirection,
664
+ softParticles,
665
+ softDistance,
666
+ orientAxis,
667
+ stretchBySpeed,
668
+ delay,
669
+ emitCount,
670
+ ]
671
+
672
+ if (debug) return
673
+
674
+ untrack(() => {
675
+ if (!_system) return
676
+
677
+ _system.setPosition(position as [number, number, number])
678
+ _system.setDelay(delay)
679
+ _system.setEmitCount(emitCount)
680
+ _system.setTurbulenceSpeed(turbulence?.speed ?? 1)
681
+
682
+ const normalized = normalizeProps({
683
+ size,
684
+ speed,
685
+ fadeSize,
686
+ fadeOpacity,
687
+ lifetime,
688
+ gravity,
689
+ direction,
690
+ startPosition,
691
+ rotation,
692
+ rotationSpeed,
693
+ friction,
694
+ intensity,
695
+ colorStart,
696
+ colorEnd,
697
+ emitterShape,
698
+ emitterRadius,
699
+ emitterAngle,
700
+ emitterHeight,
701
+ emitterSurfaceOnly,
702
+ emitterDirection,
703
+ turbulence,
704
+ attractors,
705
+ attractToCenter,
706
+ startPositionAsDirection,
707
+ softParticles,
708
+ softDistance,
709
+ collision,
710
+ orientAxis,
711
+ stretchBySpeed,
712
+ } as any)
713
+ updateUniforms(_system.uniforms, normalized)
714
+ })
715
+ })
716
+
717
+ // Frame loop
718
+ useTask((delta) => {
719
+ if (!_system || !_system.initialized) return
720
+ _system.update(delta)
721
+ if (_emitting) {
722
+ _system.autoEmit(delta)
723
+ }
724
+ })
725
+
726
+ onMount(() => {
727
+ mounted = true
728
+ initSystem()
729
+ })
730
+
731
+ onDestroy(() => {
732
+ mounted = false
733
+ if (debug) {
734
+ import('debug-vfx').then((mod) => {
735
+ mod.destroyDebugPanel()
736
+ })
737
+ }
738
+ destroySystem()
739
+ })
740
+
741
+ // Exposed API
742
+ export function spawn(
743
+ x = 0,
744
+ y = 0,
745
+ z = 0,
746
+ count = 20,
747
+ overrides: Record<string, unknown> | null = null
748
+ ) {
749
+ if (!_system) return
750
+ const [px, py, pz] = _system.position
751
+ _system.spawn(px + x, py + y, pz + z, count, overrides)
752
+ }
753
+
754
+ export function start() {
755
+ if (!_system) return
756
+ _system.start()
757
+ _emitting = true
758
+ }
759
+
760
+ export function stop() {
761
+ if (!_system) return
762
+ _system.stop()
763
+ _emitting = false
764
+ }
765
+
766
+ export function clear() {
767
+ _system?.clear()
768
+ }
769
+ </script>
770
+
771
+ {#if isWebGPUForTemplate && renderObjectForTemplate}
772
+ <T is={renderObjectForTemplate} />
773
+ {/if}
package/dist/index.d.ts CHANGED
@@ -1,2 +1,28 @@
1
+ import * as core_vfx from 'core-vfx';
2
+ import { CoreState } from 'core-vfx';
3
+ export { Appearance, AttractorConfig, AttractorType, BaseParticleProps, Blending, CollisionConfig, CurveChannel, CurveData, CurvePoint, CurveTextureResolved, CurveTextureResult, Easing, EmitterController, EmitterControllerOptions, EmitterShape, FlipbookConfig, FrictionConfig, Lighting, NormalizedParticleProps, ParticleData, Rotation3DInput, StretchConfig, TurbulenceConfig, VFXParticleSystem, VFXParticleSystemOptions, bakeCurveToArray, buildCurveTextureBin, createCombinedCurveTexture, isNonDefaultRotation, isWebGPUBackend, normalizeProps, resolveCurveTexture } from 'core-vfx';
4
+ import { Readable } from 'svelte/store';
1
5
 
2
- export { }
6
+ declare function useVFXEmitter(name: string): {
7
+ emit: (position?: [number, number, number], count?: number, overrides?: Record<string, unknown> | null) => boolean;
8
+ burst: (position?: [number, number, number], count?: number, overrides?: Record<string, unknown> | null) => boolean;
9
+ start: () => boolean;
10
+ stop: () => boolean;
11
+ clear: () => boolean;
12
+ isEmitting: () => boolean;
13
+ getUniforms: () => Record<string, unknown> | null;
14
+ getParticles: () => core_vfx.ParticleSystemRef | null;
15
+ };
16
+
17
+ declare function useVFXStore(): Readable<CoreState>;
18
+ declare function useVFXStore<T>(selector: (state: CoreState) => T): Readable<T>;
19
+ declare namespace useVFXStore {
20
+ var getState: () => CoreState;
21
+ var setState: {
22
+ (partial: CoreState | Partial<CoreState> | ((state: CoreState) => CoreState | Partial<CoreState>), replace?: false): void;
23
+ (state: CoreState | ((state: CoreState) => CoreState), replace: true): void;
24
+ };
25
+ var subscribe: (listener: (state: CoreState, prevState: CoreState) => void) => () => void;
26
+ }
27
+
28
+ export { useVFXEmitter, useVFXStore };
package/dist/index.js CHANGED
@@ -1,2 +1,114 @@
1
+ // src/useVFXEmitter.ts
2
+ import { useThrelte } from "@threlte/core";
3
+ import { coreStore, isWebGPUBackend } from "core-vfx";
4
+ function useVFXEmitter(name) {
5
+ const { renderer } = useThrelte();
6
+ const checkWebGPU = () => {
7
+ return renderer && isWebGPUBackend(renderer);
8
+ };
9
+ const getParticles = () => coreStore.getState().getParticles(name);
10
+ const emit = (position = [0, 0, 0], count = 20, overrides = null) => {
11
+ if (!checkWebGPU()) return false;
12
+ const [x, y, z] = position;
13
+ return coreStore.getState().emit(name, { x, y, z, count, overrides });
14
+ };
15
+ const burst = (position = [0, 0, 0], count = 50, overrides = null) => {
16
+ if (!checkWebGPU()) return false;
17
+ const [x, y, z] = position;
18
+ return coreStore.getState().emit(name, { x, y, z, count, overrides });
19
+ };
20
+ const start = () => {
21
+ if (!checkWebGPU()) return false;
22
+ return coreStore.getState().start(name);
23
+ };
24
+ const stop = () => {
25
+ if (!checkWebGPU()) return false;
26
+ return coreStore.getState().stop(name);
27
+ };
28
+ const clear = () => {
29
+ if (!checkWebGPU()) return false;
30
+ return coreStore.getState().clear(name);
31
+ };
32
+ const isEmitting = () => {
33
+ var _a;
34
+ if (!checkWebGPU()) return false;
35
+ const particles = getParticles();
36
+ return (_a = particles == null ? void 0 : particles.isEmitting) != null ? _a : false;
37
+ };
38
+ const getUniforms = () => {
39
+ var _a;
40
+ if (!checkWebGPU()) return null;
41
+ const particles = getParticles();
42
+ return (_a = particles == null ? void 0 : particles.uniforms) != null ? _a : null;
43
+ };
44
+ return {
45
+ emit,
46
+ burst,
47
+ start,
48
+ stop,
49
+ clear,
50
+ isEmitting,
51
+ getUniforms,
52
+ getParticles: () => checkWebGPU() ? getParticles() : null
53
+ };
54
+ }
55
+
56
+ // src/svelte-store.ts
57
+ import { coreStore as coreStore2 } from "core-vfx";
58
+ function useVFXStore(selector) {
59
+ const pick = selector != null ? selector : ((s) => s);
60
+ return {
61
+ subscribe(run) {
62
+ run(pick(coreStore2.getState()));
63
+ const unsubscribe = coreStore2.subscribe((state) => {
64
+ run(pick(state));
65
+ });
66
+ return unsubscribe;
67
+ }
68
+ };
69
+ }
70
+ useVFXStore.getState = coreStore2.getState;
71
+ useVFXStore.setState = coreStore2.setState;
72
+ useVFXStore.subscribe = coreStore2.subscribe;
73
+
1
74
  // src/index.ts
2
- console.log("threlte-vfx");
75
+ import {
76
+ Appearance,
77
+ Blending,
78
+ EmitterShape,
79
+ AttractorType,
80
+ Easing,
81
+ Lighting,
82
+ bakeCurveToArray,
83
+ createCombinedCurveTexture,
84
+ buildCurveTextureBin,
85
+ CurveChannel
86
+ } from "core-vfx";
87
+ import {
88
+ VFXParticleSystem,
89
+ EmitterController,
90
+ isWebGPUBackend as isWebGPUBackend2,
91
+ isNonDefaultRotation,
92
+ normalizeProps,
93
+ resolveCurveTexture
94
+ } from "core-vfx";
95
+ export {
96
+ Appearance,
97
+ AttractorType,
98
+ Blending,
99
+ CurveChannel,
100
+ Easing,
101
+ EmitterController,
102
+ EmitterShape,
103
+ Lighting,
104
+ VFXParticleSystem,
105
+ bakeCurveToArray,
106
+ buildCurveTextureBin,
107
+ createCombinedCurveTexture,
108
+ isNonDefaultRotation,
109
+ isWebGPUBackend2 as isWebGPUBackend,
110
+ normalizeProps,
111
+ resolveCurveTexture,
112
+ useVFXEmitter,
113
+ useVFXStore
114
+ };
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "threlte-vfx",
3
- "version": "0.0.10",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
+ "svelte": "./dist/index.js",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/mustache-dev/r3f-vfx.git"
@@ -10,7 +11,16 @@
10
11
  "exports": {
11
12
  ".": {
12
13
  "types": "./dist/index.d.ts",
14
+ "svelte": "./dist/index.js",
13
15
  "import": "./dist/index.js"
16
+ },
17
+ "./VFXParticles.svelte": {
18
+ "svelte": "./dist/VFXParticles.svelte",
19
+ "import": "./dist/VFXParticles.svelte"
20
+ },
21
+ "./VFXEmitter.svelte": {
22
+ "svelte": "./dist/VFXEmitter.svelte",
23
+ "import": "./dist/VFXEmitter.svelte"
14
24
  }
15
25
  },
16
26
  "files": [
@@ -18,15 +28,23 @@
18
28
  ],
19
29
  "scripts": {
20
30
  "dev": "tsup --watch",
21
- "build": "tsup",
31
+ "build": "tsup && cp src/*.svelte dist/",
22
32
  "typecheck": "tsc",
23
33
  "copy-readme": "cp ../../README.md README.md",
24
34
  "prepublishOnly": "bun run typecheck && bun run build && bun run copy-readme"
25
35
  },
26
36
  "dependencies": {
27
- "core-vfx": "0.0.10"
37
+ "core-vfx": "0.1.0",
38
+ "debug-vfx": "0.1.1"
39
+ },
40
+ "peerDependencies": {
41
+ "@threlte/core": ">=8.0.0",
42
+ "svelte": ">=5.0.0",
43
+ "three": ">=0.182.0"
28
44
  },
29
45
  "devDependencies": {
46
+ "@threlte/core": "8.3.1",
47
+ "svelte": "5.28.2",
30
48
  "tsup": "8.5.1",
31
49
  "typescript": "5.9.3"
32
50
  }