@volter/threejs-runtime 0.5.65
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/LICENSE +202 -0
- package/NOTICE +12 -0
- package/package.json +62 -0
- package/src/adapter/body-marks.ts +69 -0
- package/src/adapter/constraint.ts +78 -0
- package/src/adapter/first-party-navigation-system.ts +48 -0
- package/src/adapter/hierarchy-marks.ts +156 -0
- package/src/adapter/ingest/scene-capture.ts +865 -0
- package/src/adapter/ingest/structural-ids.ts +139 -0
- package/src/adapter/ingest/visible-capture-window.ts +302 -0
- package/src/adapter/object3d-authoring-subject.ts +50 -0
- package/src/adapter/rapier-physics-adapter.ts +226 -0
- package/src/adapter/reflection-probe.ts +75 -0
- package/src/adapter/renderer-config.ts +116 -0
- package/src/adapter/trigger-volume.ts +29 -0
- package/src/ai/navigation.ts +627 -0
- package/src/animation/animation-clock.ts +479 -0
- package/src/animation/runtime-inspection.ts +45 -0
- package/src/animation/xstate-animation-binding.ts +655 -0
- package/src/animation/xstate-animation-meta.ts +360 -0
- package/src/asset-formats/camera.ts +18 -0
- package/src/asset-formats/collider.ts +66 -0
- package/src/asset-formats/index.ts +45 -0
- package/src/asset-formats/light.ts +50 -0
- package/src/asset-formats/material.ts +93 -0
- package/src/asset-formats/mesh.ts +78 -0
- package/src/asset-formats/particles.ts +426 -0
- package/src/asset-formats/render-env.ts +318 -0
- package/src/asset-formats/tuples.ts +21 -0
- package/src/asset-loaders.ts +242 -0
- package/src/asset-parse-error.ts +29 -0
- package/src/assets.ts +68 -0
- package/src/behavior/xstate-inspection.ts +51 -0
- package/src/defaults.ts +135 -0
- package/src/ecs/object-marks.ts +75 -0
- package/src/ecs/scene-index.ts +439 -0
- package/src/ecs/scene-query.ts +43 -0
- package/src/ecs/user-data.ts +272 -0
- package/src/loader.ts +134 -0
- package/src/physics/collider-dimensions.ts +121 -0
- package/src/physics/collision-system.ts +76 -0
- package/src/physics/physics-registry.ts +83 -0
- package/src/physics/transform-writer.ts +41 -0
- package/src/render/directional-shadow-fit.ts +158 -0
- package/src/render/environment-capture.ts +133 -0
- package/src/render/ibl-override-material.ts +202 -0
- package/src/render/light-camera-factory.ts +103 -0
- package/src/render/matcap-texture.ts +92 -0
- package/src/render/material-factory.ts +201 -0
- package/src/render/mesh-shadow.ts +18 -0
- package/src/render/particles-factory.ts +589 -0
- package/src/render/render-batch-system.ts +260 -0
- package/src/render/render-features.ts +128 -0
- package/src/render/render-settings.ts +71 -0
- package/src/render/soft-particle-depth.ts +360 -0
- package/src/render/spark-renderer-lifecycle.ts +64 -0
- package/src/render/structural-signature.ts +231 -0
- package/src/render/viewport-shading.ts +163 -0
- package/src/render/voxel-surface.ts +358 -0
- package/src/setup/setup-particles.ts +32 -0
- package/src/setup/setup-physics.ts +67 -0
- package/src/setup/setup-renderer.ts +524 -0
- package/src/types-n8ao.d.ts +37 -0
- package/src/types-realism-effects.d.ts +61 -0
- package/vendor/realism-effects/LICENSE.md +21 -0
- package/vendor/realism-effects/UPSTREAM.md +19 -0
- package/vendor/realism-effects/dist/index.cjs +3447 -0
- package/vendor/realism-effects/dist/index.d.ts +59 -0
- package/vendor/realism-effects/dist/index.js +3434 -0
- package/vendor/realism-effects/package.json +23 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
// biome-ignore-all lint/suspicious/noExplicitAny: quarks' API uses untyped FunctionJSON internally; our Zod types are structurally compatible but TS can't unify them
|
|
2
|
+
/**
|
|
3
|
+
* Converts ParticlesDescriptor JSON → three.quarks ParticleSystem.
|
|
4
|
+
*
|
|
5
|
+
* A published engine entry point (the export map is the wildcard `./*`): user
|
|
6
|
+
* games outside this repo reach it directly, and the in-repo callers are the
|
|
7
|
+
* editor's own selection-bounds proof and `defaultParticlesData`'s consumers.
|
|
8
|
+
* The schema mirrors quarks' native JSON format, so most fields pass through
|
|
9
|
+
* directly to quarks' fromJSON or constructor methods.
|
|
10
|
+
*
|
|
11
|
+
* It used to name `godot-compat`'s `createCpuParticles3D` as its one in-repo
|
|
12
|
+
* caller; that lane is ARCHIVED off main — `git fetch origin
|
|
13
|
+
* archive/godot-lane`, tag `archive/godot-lane-2026-09-19`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
ApplyForce,
|
|
18
|
+
ChangeEmitDirection,
|
|
19
|
+
CircleEmitter,
|
|
20
|
+
type ColorGenerator,
|
|
21
|
+
ColorGeneratorFromJSON,
|
|
22
|
+
ColorOverLife,
|
|
23
|
+
ConeEmitter,
|
|
24
|
+
DonutEmitter,
|
|
25
|
+
type EmitterShape,
|
|
26
|
+
ForceOverLife,
|
|
27
|
+
FrameOverLife,
|
|
28
|
+
type FunctionColorGenerator,
|
|
29
|
+
GravityForce,
|
|
30
|
+
GridEmitter,
|
|
31
|
+
HemisphereEmitter,
|
|
32
|
+
LimitSpeedOverLife,
|
|
33
|
+
Noise,
|
|
34
|
+
OrbitOverLife,
|
|
35
|
+
PointEmitter,
|
|
36
|
+
Vector3 as QVector3,
|
|
37
|
+
RectangleEmitter,
|
|
38
|
+
RotationOverLife,
|
|
39
|
+
SizeOverLife,
|
|
40
|
+
SpeedOverLife,
|
|
41
|
+
SphereEmitter,
|
|
42
|
+
TurbulenceField,
|
|
43
|
+
ValueGeneratorFromJSON,
|
|
44
|
+
WidthOverLength,
|
|
45
|
+
} from 'quarks.core';
|
|
46
|
+
import * as THREE from 'three';
|
|
47
|
+
import { type BatchedRenderer, ParticleSystem, RenderMode } from 'three.quarks';
|
|
48
|
+
import type {
|
|
49
|
+
BehaviorJSON,
|
|
50
|
+
ColorGeneratorJSON,
|
|
51
|
+
EmitterShapeJSON,
|
|
52
|
+
ParticlesDescriptor,
|
|
53
|
+
ValueGeneratorJSON,
|
|
54
|
+
} from '../asset-formats/particles';
|
|
55
|
+
import { armSoftParticleDepth, disarmSoftParticleDepth } from './soft-particle-depth';
|
|
56
|
+
|
|
57
|
+
// --- Render mode mapping ---
|
|
58
|
+
|
|
59
|
+
const RENDER_MODE_MAP: Record<string, RenderMode> = {
|
|
60
|
+
billboard: RenderMode.BillBoard,
|
|
61
|
+
stretchedBillboard: RenderMode.StretchedBillBoard,
|
|
62
|
+
mesh: RenderMode.Mesh,
|
|
63
|
+
trail: RenderMode.Trail,
|
|
64
|
+
horizontalBillboard: RenderMode.HorizontalBillBoard,
|
|
65
|
+
verticalBillboard: RenderMode.VerticalBillBoard,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// --- Texture loader (shared cache) ---
|
|
69
|
+
|
|
70
|
+
import { textureLoader } from '../loader';
|
|
71
|
+
|
|
72
|
+
type ParticleMapSampler = NonNullable<ParticlesDescriptor['material']['mapSampler']>;
|
|
73
|
+
|
|
74
|
+
const WRAP: Record<NonNullable<ParticleMapSampler['wrap']>, THREE.Wrapping> = {
|
|
75
|
+
clamp: THREE.ClampToEdgeWrapping,
|
|
76
|
+
repeat: THREE.RepeatWrapping,
|
|
77
|
+
mirror: THREE.MirroredRepeatWrapping,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** `minFilter` is where the two sampler axes MEET — `filter` chooses linear vs nearest and
|
|
81
|
+
* `mipmaps` chooses whether the mip chain is sampled at all — and three spells the four
|
|
82
|
+
* combinations as four constants. A table rather than nested conditionals, which read as a
|
|
83
|
+
* puzzle. (`magFilter` has no mip half; it is the `filter` axis alone.) */
|
|
84
|
+
const MIN_FILTER = {
|
|
85
|
+
'linear/mips': THREE.LinearMipmapLinearFilter,
|
|
86
|
+
'linear/no-mips': THREE.LinearFilter,
|
|
87
|
+
'nearest/mips': THREE.NearestMipmapNearestFilter,
|
|
88
|
+
'nearest/no-mips': THREE.NearestFilter,
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
const particleTexCache = new Map<string, THREE.Texture>();
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The sprite texture for a material's `map`, loaded once per URL+sampler.
|
|
95
|
+
*
|
|
96
|
+
* Two things are decided here rather than left to three's loader defaults:
|
|
97
|
+
*
|
|
98
|
+
* - **COLOUR SPACE.** `THREE.TextureLoader` leaves `colorSpace` at `NoColorSpace`, which tells
|
|
99
|
+
* three the texels are already linear — so an ordinary sRGB PNG sprite is composited
|
|
100
|
+
* UNDECODED and every mid-tone comes out far too bright. A `map` is a colour texture by
|
|
101
|
+
* definition (three's own `GLTFLoader` states `SRGBColorSpace` on `baseColorTexture` for the
|
|
102
|
+
* same reason), so this states sRGB unconditionally. It is not a descriptor field: there is no
|
|
103
|
+
* correct second value for this slot. Measured 2026-08-14 against real Godot 3.6/GLES3 — a
|
|
104
|
+
* 128,128,128 sprite read back 0.5015 there and 0.7373 here, and after this line 0.5019; the
|
|
105
|
+
* whole A/B is in `godot-compat/cpu-particles-3d.ts`'s header.
|
|
106
|
+
* - **SAMPLER.** `material.mapSampler`, when the descriptor carries one. The cache key includes
|
|
107
|
+
* it because two emitters may name ONE image and want different sampling of it — a URL-only
|
|
108
|
+
* key would hand the second one the first's configured texture.
|
|
109
|
+
*
|
|
110
|
+
* `flipY` is deliberately untouched: the billboard quad this shader draws is built by
|
|
111
|
+
* `three.quarks` with three's own V-up UVs, so three's `flipY = true` is the consistent half of
|
|
112
|
+
* that pair.
|
|
113
|
+
*/
|
|
114
|
+
function loadParticleTexture(url: string, sampler?: ParticleMapSampler): THREE.Texture {
|
|
115
|
+
const key = sampler === undefined ? url : `${url}\u0000${JSON.stringify(sampler)}`;
|
|
116
|
+
const cached = particleTexCache.get(key);
|
|
117
|
+
if (cached) return cached;
|
|
118
|
+
const tex = textureLoader.load(url);
|
|
119
|
+
tex.colorSpace = THREE.SRGBColorSpace;
|
|
120
|
+
if (sampler !== undefined) applyMapSampler(tex, sampler);
|
|
121
|
+
particleTexCache.set(key, tex);
|
|
122
|
+
return tex;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One `mapSampler` onto three's own texture properties. An unstated field is left at three's
|
|
126
|
+
* default rather than restated — see {@link loadParticleTexture}. */
|
|
127
|
+
function applyMapSampler(tex: THREE.Texture, sampler: ParticleMapSampler): void {
|
|
128
|
+
if (sampler.wrap !== undefined) {
|
|
129
|
+
tex.wrapS = WRAP[sampler.wrap];
|
|
130
|
+
tex.wrapT = WRAP[sampler.wrap];
|
|
131
|
+
}
|
|
132
|
+
if (sampler.filter !== undefined || sampler.mipmaps !== undefined) {
|
|
133
|
+
// An unstated axis takes three's own default, which is linear and mipmapped.
|
|
134
|
+
const filter = sampler.filter ?? 'linear';
|
|
135
|
+
const mips = sampler.mipmaps === false ? 'no-mips' : 'mips';
|
|
136
|
+
if (sampler.filter !== undefined) {
|
|
137
|
+
tex.magFilter = filter === 'linear' ? THREE.LinearFilter : THREE.NearestFilter;
|
|
138
|
+
}
|
|
139
|
+
tex.minFilter = MIN_FILTER[`${filter}/${mips}`];
|
|
140
|
+
}
|
|
141
|
+
if (sampler.mipmaps !== undefined) tex.generateMipmaps = sampler.mipmaps;
|
|
142
|
+
// Anisotropy rides through unclamped: three's own `WebGLTextures` uploads
|
|
143
|
+
// `Math.min(texture.anisotropy, capabilities.getMaxAnisotropy())`, so the renderer's real
|
|
144
|
+
// capability is what binds and a second clamp here would have no effect.
|
|
145
|
+
if (sampler.anisotropy !== undefined) tex.anisotropy = sampler.anisotropy;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- Helpers ---
|
|
149
|
+
|
|
150
|
+
/** Strip keys with undefined values so exactOptionalPropertyTypes is satisfied. */
|
|
151
|
+
function defined(obj: Record<string, any>): any {
|
|
152
|
+
const result: Record<string, unknown> = {};
|
|
153
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
154
|
+
if (v !== undefined) result[k] = v;
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- Emitter shape ---
|
|
160
|
+
|
|
161
|
+
function createEmitterShape(json: EmitterShapeJSON) {
|
|
162
|
+
switch (json.type) {
|
|
163
|
+
case 'point':
|
|
164
|
+
return new PointEmitter();
|
|
165
|
+
case 'sphere':
|
|
166
|
+
return new SphereEmitter(
|
|
167
|
+
defined({ radius: json.radius, arc: json.arc, thickness: json.thickness }),
|
|
168
|
+
);
|
|
169
|
+
case 'hemisphere':
|
|
170
|
+
return new HemisphereEmitter(
|
|
171
|
+
defined({ radius: json.radius, arc: json.arc, thickness: json.thickness }),
|
|
172
|
+
);
|
|
173
|
+
case 'cone':
|
|
174
|
+
return new ConeEmitter(
|
|
175
|
+
defined({
|
|
176
|
+
radius: json.radius,
|
|
177
|
+
arc: json.arc,
|
|
178
|
+
thickness: json.thickness,
|
|
179
|
+
angle: json.angle,
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
case 'circle':
|
|
183
|
+
return new CircleEmitter(
|
|
184
|
+
defined({ radius: json.radius, arc: json.arc, thickness: json.thickness }),
|
|
185
|
+
);
|
|
186
|
+
case 'donut':
|
|
187
|
+
return new DonutEmitter(
|
|
188
|
+
defined({
|
|
189
|
+
radius: json.radius,
|
|
190
|
+
arc: json.arc,
|
|
191
|
+
thickness: json.thickness,
|
|
192
|
+
donutRadius: json.donutRadius,
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
case 'rectangle':
|
|
196
|
+
return new RectangleEmitter(
|
|
197
|
+
defined({ width: json.width, height: json.height, thickness: json.thickness }),
|
|
198
|
+
);
|
|
199
|
+
case 'grid':
|
|
200
|
+
return new GridEmitter(
|
|
201
|
+
defined({ width: json.width, height: json.height, column: json.column, row: json.row }),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- Value / Color generators ---
|
|
207
|
+
// Our Zod-inferred types are structurally identical to quarks' FunctionJSON
|
|
208
|
+
// but TypeScript can't unify them, so we cast through the untyped bridge.
|
|
209
|
+
|
|
210
|
+
function valueGen(json: ValueGeneratorJSON) {
|
|
211
|
+
return ValueGeneratorFromJSON(json as any);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function colorGen(json: ColorGeneratorJSON) {
|
|
215
|
+
return ColorGeneratorFromJSON(json as any);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --- Behaviors ---
|
|
219
|
+
|
|
220
|
+
function createBehavior(json: BehaviorJSON): any {
|
|
221
|
+
switch (json.type) {
|
|
222
|
+
case 'ApplyForce':
|
|
223
|
+
return new ApplyForce(
|
|
224
|
+
new QVector3(json.direction[0], json.direction[1], json.direction[2]),
|
|
225
|
+
valueGen(json.magnitude) as any,
|
|
226
|
+
);
|
|
227
|
+
case 'ColorOverLife':
|
|
228
|
+
return new ColorOverLife(colorGen(json.color) as any);
|
|
229
|
+
case 'SizeOverLife':
|
|
230
|
+
return new SizeOverLife(valueGen(json.size) as any);
|
|
231
|
+
case 'SpeedOverLife':
|
|
232
|
+
return new SpeedOverLife(valueGen(json.speed) as any);
|
|
233
|
+
case 'RotationOverLife':
|
|
234
|
+
return new RotationOverLife(valueGen(json.angularVelocity) as any);
|
|
235
|
+
case 'ForceOverLife':
|
|
236
|
+
return new ForceOverLife(
|
|
237
|
+
valueGen(json.x) as any,
|
|
238
|
+
valueGen(json.y) as any,
|
|
239
|
+
valueGen(json.z) as any,
|
|
240
|
+
);
|
|
241
|
+
case 'OrbitOverLife':
|
|
242
|
+
return new OrbitOverLife(
|
|
243
|
+
valueGen(json.orbitSpeed) as any,
|
|
244
|
+
json.axis ? new QVector3(json.axis[0], json.axis[1], json.axis[2]) : undefined,
|
|
245
|
+
);
|
|
246
|
+
case 'Noise':
|
|
247
|
+
return new Noise(
|
|
248
|
+
valueGen(json.frequency) as any,
|
|
249
|
+
valueGen(json.power) as any,
|
|
250
|
+
json.positionAmount ? (valueGen(json.positionAmount) as any) : undefined,
|
|
251
|
+
json.rotationAmount ? (valueGen(json.rotationAmount) as any) : undefined,
|
|
252
|
+
);
|
|
253
|
+
case 'TurbulenceField':
|
|
254
|
+
return new TurbulenceField(
|
|
255
|
+
new QVector3(json.scale[0], json.scale[1], json.scale[2]),
|
|
256
|
+
json.octaves,
|
|
257
|
+
new QVector3(
|
|
258
|
+
json.velocityMultiplier[0],
|
|
259
|
+
json.velocityMultiplier[1],
|
|
260
|
+
json.velocityMultiplier[2],
|
|
261
|
+
),
|
|
262
|
+
new QVector3(json.timeScale[0], json.timeScale[1], json.timeScale[2]),
|
|
263
|
+
);
|
|
264
|
+
case 'FrameOverLife':
|
|
265
|
+
return new FrameOverLife(valueGen(json.frame) as any);
|
|
266
|
+
case 'LimitSpeedOverLife':
|
|
267
|
+
return new LimitSpeedOverLife(valueGen(json.speed) as any, json.dampen);
|
|
268
|
+
case 'ChangeEmitDirection':
|
|
269
|
+
return new ChangeEmitDirection(valueGen(json.angle) as any);
|
|
270
|
+
case 'GravityForce':
|
|
271
|
+
return new GravityForce(
|
|
272
|
+
new QVector3(json.center[0], json.center[1], json.center[2]),
|
|
273
|
+
json.magnitude,
|
|
274
|
+
);
|
|
275
|
+
case 'WidthOverLength':
|
|
276
|
+
return new WidthOverLength(valueGen(json.width) as any);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- Material ---
|
|
281
|
+
|
|
282
|
+
function createParticleMaterial(mat: ParticlesDescriptor['material']): THREE.Material {
|
|
283
|
+
const textures: Record<string, THREE.Texture> = {};
|
|
284
|
+
if (mat.map) textures['map'] = loadParticleTexture(mat.map, mat.mapSampler);
|
|
285
|
+
|
|
286
|
+
const blending = mat.blending === 'normal' ? THREE.NormalBlending : THREE.AdditiveBlending;
|
|
287
|
+
const transparent = mat.transparent !== false;
|
|
288
|
+
const depthWrite = mat.depthWrite ?? false;
|
|
289
|
+
const side =
|
|
290
|
+
mat.side === 'back'
|
|
291
|
+
? THREE.BackSide
|
|
292
|
+
: mat.side === 'double'
|
|
293
|
+
? THREE.DoubleSide
|
|
294
|
+
: THREE.FrontSide;
|
|
295
|
+
|
|
296
|
+
if (mat.type === 'standard') {
|
|
297
|
+
return new THREE.MeshStandardMaterial({
|
|
298
|
+
color: mat.color ?? '#ffffff',
|
|
299
|
+
...textures,
|
|
300
|
+
blending,
|
|
301
|
+
transparent,
|
|
302
|
+
depthWrite,
|
|
303
|
+
side,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return new THREE.MeshBasicMaterial({
|
|
308
|
+
color: mat.color ?? '#ffffff',
|
|
309
|
+
...textures,
|
|
310
|
+
blending,
|
|
311
|
+
transparent,
|
|
312
|
+
depthWrite,
|
|
313
|
+
side,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// --- Default sprite texture (soft radial gradient) ---
|
|
318
|
+
|
|
319
|
+
let _defaultSpriteTex: THREE.Texture | null = null;
|
|
320
|
+
|
|
321
|
+
function getDefaultSpriteTex(): THREE.Texture {
|
|
322
|
+
if (_defaultSpriteTex) return _defaultSpriteTex;
|
|
323
|
+
const canvas = document.createElement('canvas');
|
|
324
|
+
canvas.width = 16;
|
|
325
|
+
canvas.height = 16;
|
|
326
|
+
const c2d = canvas.getContext('2d')!;
|
|
327
|
+
const grad = c2d.createRadialGradient(8, 8, 0, 8, 8, 8);
|
|
328
|
+
grad.addColorStop(0, 'rgba(255,255,255,1)');
|
|
329
|
+
grad.addColorStop(1, 'rgba(255,255,255,0)');
|
|
330
|
+
c2d.fillStyle = grad;
|
|
331
|
+
c2d.fillRect(0, 0, 16, 16);
|
|
332
|
+
_defaultSpriteTex = new THREE.CanvasTexture(canvas);
|
|
333
|
+
return _defaultSpriteTex;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// --- Public API ---
|
|
337
|
+
|
|
338
|
+
export interface ParticleSystemResult {
|
|
339
|
+
/** The emitter Object3D (add to scene for transform) */
|
|
340
|
+
emitter: THREE.Object3D;
|
|
341
|
+
/** The ParticleSystem instance (register with BatchedRenderer) */
|
|
342
|
+
system: ParticleSystem;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The non-JSON companion to a {@link ParticlesDescriptor} — the runtime objects
|
|
347
|
+
* a JSON document cannot carry, and an authoring toggle. Every field is
|
|
348
|
+
* optional and the whole argument is optional, so existing single-argument
|
|
349
|
+
* callers are unchanged.
|
|
350
|
+
*
|
|
351
|
+
* This is how a caller with fidelity a plain descriptor cannot express reaches
|
|
352
|
+
* the SAME factory instead of hand-building its own `new ParticleSystem(...)`:
|
|
353
|
+
* a foreign-engine carry (e.g. Godot CPUParticles) supplies its own
|
|
354
|
+
* `EmitterShape`, start-colour generator, and instancing geometry here, and
|
|
355
|
+
* expresses everything else — lifecycle, scalar start values, emission,
|
|
356
|
+
* behaviors, material — as descriptor JSON.
|
|
357
|
+
*/
|
|
358
|
+
export interface ParticleSystemObjects {
|
|
359
|
+
/**
|
|
360
|
+
* A pre-built emitter shape for a spawn distribution the JSON `shape` field
|
|
361
|
+
* cannot express (a `three.quarks` `EmitterShape` plugin instance). When
|
|
362
|
+
* present it REPLACES `data.shape`.
|
|
363
|
+
*/
|
|
364
|
+
shape?: EmitterShape;
|
|
365
|
+
/**
|
|
366
|
+
* A pre-built start-colour generator for a per-particle draw the descriptor's
|
|
367
|
+
* JSON generator vocabulary cannot express. When present it REPLACES
|
|
368
|
+
* `data.startColor`.
|
|
369
|
+
*/
|
|
370
|
+
startColor?: ColorGenerator | FunctionColorGenerator;
|
|
371
|
+
/**
|
|
372
|
+
* The geometry instanced per particle in `RenderMode.Mesh`. A
|
|
373
|
+
* `BufferGeometry` is not JSON-serializable, so `renderMode: 'mesh'` requires
|
|
374
|
+
* the caller to resolve and pass it here.
|
|
375
|
+
*/
|
|
376
|
+
instancingGeometry?: THREE.BufferGeometry;
|
|
377
|
+
/**
|
|
378
|
+
* When the material has no texture `map`, the factory substitutes a soft
|
|
379
|
+
* radial default sprite so a freshly-authored billboard shows something.
|
|
380
|
+
* Pass `false` to keep the material's own (map-less) appearance — a faithful
|
|
381
|
+
* carry of a foreign engine whose particle draws a plain colored quad, and
|
|
382
|
+
* the way that carry avoids the default-sprite canvas entirely. Defaults to
|
|
383
|
+
* `true`.
|
|
384
|
+
*/
|
|
385
|
+
defaultSprite?: boolean;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Create a three.quarks ParticleSystem from ParticlesDescriptor JSON data.
|
|
390
|
+
*
|
|
391
|
+
* `objects` carries the pieces a JSON descriptor cannot: a plugin emitter
|
|
392
|
+
* shape, a start-colour generator, a mesh-mode instancing geometry, and the
|
|
393
|
+
* default-sprite toggle. It is optional and additive — a call with only `data`
|
|
394
|
+
* behaves exactly as before.
|
|
395
|
+
*/
|
|
396
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: straightforward field-by-field mapping
|
|
397
|
+
export function createParticleSystemFromData(
|
|
398
|
+
data: ParticlesDescriptor,
|
|
399
|
+
objects?: ParticleSystemObjects,
|
|
400
|
+
): ParticleSystemResult {
|
|
401
|
+
const material = createParticleMaterial(data.material);
|
|
402
|
+
|
|
403
|
+
// Apply default sprite if no texture specified (unless the caller opts out to
|
|
404
|
+
// keep a map-less material faithful to its source).
|
|
405
|
+
if ((objects?.defaultSprite ?? true) && !data.material.map && 'map' in material) {
|
|
406
|
+
(material as THREE.MeshBasicMaterial).map = getDefaultSpriteTex();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Build params, strip undefined keys for exactOptionalPropertyTypes compat
|
|
410
|
+
const params: Record<string, unknown> = { material };
|
|
411
|
+
|
|
412
|
+
if (data.autoDestroy != null) params['autoDestroy'] = data.autoDestroy;
|
|
413
|
+
if (data.looping != null) params['looping'] = data.looping;
|
|
414
|
+
if (data.prewarm != null) params['prewarm'] = data.prewarm;
|
|
415
|
+
if (data.duration != null) params['duration'] = data.duration;
|
|
416
|
+
// A pre-built shape (a distribution the JSON cannot express) wins over the
|
|
417
|
+
// descriptor's own shape; otherwise build the shape from JSON.
|
|
418
|
+
if (objects?.shape) params['shape'] = objects.shape;
|
|
419
|
+
else if (data.shape) params['shape'] = createEmitterShape(data.shape);
|
|
420
|
+
if (objects?.instancingGeometry) params['instancingGeometry'] = objects.instancingGeometry;
|
|
421
|
+
|
|
422
|
+
if (data.startLife) params['startLife'] = valueGen(data.startLife);
|
|
423
|
+
if (data.startSpeed) params['startSpeed'] = valueGen(data.startSpeed);
|
|
424
|
+
if (data.startSize) params['startSize'] = valueGen(data.startSize);
|
|
425
|
+
if (data.startRotation) params['startRotation'] = valueGen(data.startRotation);
|
|
426
|
+
if (objects?.startColor) params['startColor'] = objects.startColor;
|
|
427
|
+
else if (data.startColor) params['startColor'] = colorGen(data.startColor);
|
|
428
|
+
if (data.startTileIndex) params['startTileIndex'] = valueGen(data.startTileIndex);
|
|
429
|
+
|
|
430
|
+
// Trail mode requires startLength inside rendererEmitterSettings, not as a top-level param
|
|
431
|
+
if (data.renderMode === 'trail' && data.startLength) {
|
|
432
|
+
params['rendererEmitterSettings'] = { startLength: valueGen(data.startLength) };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (data.emissionOverTime) params['emissionOverTime'] = valueGen(data.emissionOverTime);
|
|
436
|
+
if (data.emissionOverDistance)
|
|
437
|
+
params['emissionOverDistance'] = valueGen(data.emissionOverDistance);
|
|
438
|
+
if (data.emissionBursts) {
|
|
439
|
+
params['emissionBursts'] = data.emissionBursts.map((b) => ({
|
|
440
|
+
time: b.time,
|
|
441
|
+
count: valueGen(b.count),
|
|
442
|
+
cycle: b.cycle ?? 1,
|
|
443
|
+
interval: b.interval ?? 0,
|
|
444
|
+
probability: b.probability ?? 1,
|
|
445
|
+
}));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (data.behaviors) params['behaviors'] = data.behaviors.map(createBehavior);
|
|
449
|
+
if (data.renderMode != null) params['renderMode'] = RENDER_MODE_MAP[data.renderMode];
|
|
450
|
+
if (data.worldSpace != null) params['worldSpace'] = data.worldSpace;
|
|
451
|
+
if (data.renderOrder != null) params['renderOrder'] = data.renderOrder;
|
|
452
|
+
if (data.uTileCount != null) params['uTileCount'] = data.uTileCount;
|
|
453
|
+
if (data.vTileCount != null) params['vTileCount'] = data.vTileCount;
|
|
454
|
+
if (data.blendTiles != null) params['blendTiles'] = data.blendTiles;
|
|
455
|
+
if (data.softParticles != null) params['softParticles'] = data.softParticles;
|
|
456
|
+
if (data.softFarFade != null) params['softFarFade'] = data.softFarFade;
|
|
457
|
+
if (data.softNearFade != null) params['softNearFade'] = data.softNearFade;
|
|
458
|
+
|
|
459
|
+
const ps = new ParticleSystem(params as any);
|
|
460
|
+
|
|
461
|
+
return { emitter: ps.emitter, system: ps };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Patch a RUNNING ParticleSystem in place from ParticlesDescriptor JSON — the
|
|
466
|
+
* editor's live-tune path (W1b). Unlike `createParticleSystemFromData`, this
|
|
467
|
+
* does NOT recreate the system: the particle pool, per-particle ages, and the
|
|
468
|
+
* emission clock all survive, so a curve-key drag reshapes the live emitter
|
|
469
|
+
* with no restart stutter.
|
|
470
|
+
*
|
|
471
|
+
* Covers the live-tunable subset: lifecycle scalars (duration/looping/
|
|
472
|
+
* prewarm), emitter shape, start-value generators, emission rates + bursts,
|
|
473
|
+
* and the behavior stack (behavior instances are rebuilt from JSON — quarks
|
|
474
|
+
* behaviors read their generators per-update, so existing particles pick the
|
|
475
|
+
* new curves up on the next frame).
|
|
476
|
+
*
|
|
477
|
+
* NOT covered (these change the render batch and must go through the normal
|
|
478
|
+
* teardown/rebuild path): material, renderMode, tiling/soft-particle render
|
|
479
|
+
* settings, worldSpace.
|
|
480
|
+
*/
|
|
481
|
+
export function applyParticlesDataLive(system: ParticleSystem, data: ParticlesDescriptor): void {
|
|
482
|
+
if (data.duration != null) system.duration = data.duration;
|
|
483
|
+
if (data.looping != null) system.looping = data.looping;
|
|
484
|
+
if (data.prewarm != null) system.prewarm = data.prewarm;
|
|
485
|
+
if (data.shape) system.emitterShape = createEmitterShape(data.shape);
|
|
486
|
+
if (data.startLife) system.startLife = valueGen(data.startLife) as any;
|
|
487
|
+
if (data.startSpeed) system.startSpeed = valueGen(data.startSpeed) as any;
|
|
488
|
+
if (data.startSize) system.startSize = valueGen(data.startSize) as any;
|
|
489
|
+
if (data.startRotation) system.startRotation = valueGen(data.startRotation) as any;
|
|
490
|
+
if (data.startColor) system.startColor = colorGen(data.startColor) as any;
|
|
491
|
+
if (data.startTileIndex) system.startTileIndex = valueGen(data.startTileIndex) as any;
|
|
492
|
+
if (data.emissionOverTime) system.emissionOverTime = valueGen(data.emissionOverTime) as any;
|
|
493
|
+
if (data.emissionOverDistance)
|
|
494
|
+
system.emissionOverDistance = valueGen(data.emissionOverDistance) as any;
|
|
495
|
+
system.emissionBursts = (data.emissionBursts ?? []).map((b) => ({
|
|
496
|
+
time: b.time,
|
|
497
|
+
count: valueGen(b.count) as any,
|
|
498
|
+
cycle: b.cycle ?? 1,
|
|
499
|
+
interval: b.interval ?? 0,
|
|
500
|
+
probability: b.probability ?? 1,
|
|
501
|
+
}));
|
|
502
|
+
system.behaviors = (data.behaviors ?? []).map(createBehavior);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Scrub a ParticleSystem to an absolute time by deterministic re-simulation:
|
|
507
|
+
* restart, then advance in fixed steps to `time`, then pause. Used by the
|
|
508
|
+
* editor's emitter-transport time scrub. `update` is TS-private on
|
|
509
|
+
* ParticleSystem but is exactly what BatchedRenderer.update calls per system.
|
|
510
|
+
*/
|
|
511
|
+
export function scrubParticleSystemTo(system: ParticleSystem, time: number, step = 1 / 60): void {
|
|
512
|
+
system.restart();
|
|
513
|
+
const target = Math.max(0, time);
|
|
514
|
+
let t = 0;
|
|
515
|
+
while (t < target) {
|
|
516
|
+
const dt = Math.min(step, target - t);
|
|
517
|
+
(system as any).update(dt);
|
|
518
|
+
t += dt;
|
|
519
|
+
}
|
|
520
|
+
system.pause();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Register a particle system with a BatchedRenderer.
|
|
525
|
+
*
|
|
526
|
+
* This is also the ONE seat that arms the scene-depth prepass: a system built
|
|
527
|
+
* with `softParticles` compiles its batch with three.quarks' `SOFT_PARTICLES`
|
|
528
|
+
* define, which samples a `depthTexture` uniform nothing else in this engine
|
|
529
|
+
* would ever fill (see `render/soft-particle-depth.ts` for what an unfilled one
|
|
530
|
+
* does to the frame). Arming here — rather than in
|
|
531
|
+
* {@link createParticleSystemFromData} — is what makes it correct: the uniform
|
|
532
|
+
* lives on the BATCH, and no batch exists until a renderer is handed the
|
|
533
|
+
* system.
|
|
534
|
+
*/
|
|
535
|
+
export function registerParticleSystem(
|
|
536
|
+
batchedRenderer: BatchedRenderer,
|
|
537
|
+
system: ParticleSystem,
|
|
538
|
+
): void {
|
|
539
|
+
batchedRenderer.addSystem(system);
|
|
540
|
+
if (system.softParticles) armSoftParticleDepth(batchedRenderer);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Unregister a particle system from a BatchedRenderer.
|
|
545
|
+
*
|
|
546
|
+
* Disarms the depth prepass — one decrement against the arming above, so a
|
|
547
|
+
* renderer hosting several soft systems stays armed until its last one leaves.
|
|
548
|
+
*/
|
|
549
|
+
export function unregisterParticleSystem(
|
|
550
|
+
batchedRenderer: BatchedRenderer,
|
|
551
|
+
system: ParticleSystem,
|
|
552
|
+
): void {
|
|
553
|
+
batchedRenderer.deleteSystem(system);
|
|
554
|
+
if (system.softParticles) disarmSoftParticleDepth(batchedRenderer);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Default particle data for creating a new particle emitter via AddSectionMenu.
|
|
559
|
+
*/
|
|
560
|
+
export function defaultParticlesData(): ParticlesDescriptor {
|
|
561
|
+
return {
|
|
562
|
+
looping: true,
|
|
563
|
+
duration: 2,
|
|
564
|
+
shape: { type: 'cone', radius: 0.1, angle: 0.3 },
|
|
565
|
+
startLife: { type: 'IntervalValue', a: 0.5, b: 1.5 },
|
|
566
|
+
startSpeed: { type: 'IntervalValue', a: 1, b: 3 },
|
|
567
|
+
startSize: { type: 'IntervalValue', a: 0.05, b: 0.15 },
|
|
568
|
+
startColor: {
|
|
569
|
+
type: 'ConstantColor',
|
|
570
|
+
color: { r: 1, g: 0.8, b: 0.2, a: 1 },
|
|
571
|
+
},
|
|
572
|
+
emissionOverTime: { type: 'ConstantValue', value: 30 },
|
|
573
|
+
behaviors: [
|
|
574
|
+
{
|
|
575
|
+
type: 'SizeOverLife',
|
|
576
|
+
size: {
|
|
577
|
+
type: 'PiecewiseBezier',
|
|
578
|
+
functions: [{ function: { p0: 1, p1: 0.67, p2: 0.33, p3: 0 }, start: 0 }],
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
material: {
|
|
583
|
+
type: 'basic',
|
|
584
|
+
blending: 'additive',
|
|
585
|
+
transparent: true,
|
|
586
|
+
depthWrite: false,
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
}
|