@aethervtt/shard-particles 0.0.1
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 +21 -0
- package/README.md +3 -0
- package/dist/components.d.ts +13 -0
- package/dist/components.js +45 -0
- package/dist/effect.d.ts +85 -0
- package/dist/effect.js +333 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +56 -0
- package/dist/modules.d.ts +41 -0
- package/dist/modules.js +292 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +39 -0
- package/dist/preview-render.d.ts +4 -0
- package/dist/preview-render.js +68 -0
- package/dist/preview.d.ts +2 -0
- package/dist/preview.js +11 -0
- package/dist/shaders.d.ts +15 -0
- package/dist/shaders.js +347 -0
- package/dist/sim.d.ts +139 -0
- package/dist/sim.js +962 -0
- package/dist/values.d.ts +33 -0
- package/dist/values.js +199 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Dias
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const ParticleSystem: import("@aethervtt/shard-core").ComponentDef<{
|
|
2
|
+
readonly effect: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").AssetRef<"ParticleEffect"> | null, "object">;
|
|
3
|
+
readonly playing: import("@aethervtt/shard-core").FieldType<boolean, "u8">;
|
|
4
|
+
readonly seed: import("@aethervtt/shard-core").FieldType<number, "u32">;
|
|
5
|
+
readonly timeScale: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
6
|
+
readonly space: import("@aethervtt/shard-core").FieldType<"local" | "world", "u8">;
|
|
7
|
+
readonly backend: import("@aethervtt/shard-core").FieldType<"cpu" | "gpu", "u8">;
|
|
8
|
+
}>;
|
|
9
|
+
export declare const ParticleEmitterOverrides: import("@aethervtt/shard-core").ComponentDef<{
|
|
10
|
+
readonly emitter: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
11
|
+
readonly spawnRate: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
12
|
+
readonly spawnScale: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { defineComponent, t } from "@aethervtt/shard-core";
|
|
2
|
+
import { Visibility } from "@aethervtt/shard-render";
|
|
3
|
+
import { Transform } from "@aethervtt/shard-transform";
|
|
4
|
+
const ParticleSystem = defineComponent(
|
|
5
|
+
"particles/ParticleSystem",
|
|
6
|
+
{
|
|
7
|
+
effect: t.handle("ParticleEffect", { description: "The effect (*.particles.json) to run." }),
|
|
8
|
+
playing: t.bool({ default: true, description: "Spawns and simulates. Off: frozen in place." }),
|
|
9
|
+
seed: t.u32({ description: "Random seed: the same seed replays the same particles." }),
|
|
10
|
+
timeScale: t.f32({ default: 1, min: 0, description: "Simulation speed." }),
|
|
11
|
+
space: t.enum(["world", "local"], {
|
|
12
|
+
description: "world: particles stay where they were emitted (a moving ship leaves its exhaust behind). local: they move with the entity (an engine's glow)."
|
|
13
|
+
}),
|
|
14
|
+
backend: t.enum(["gpu", "cpu"], {
|
|
15
|
+
description: "gpu: compute shaders, for any count. cpu: the same effect simulated in TypeScript, for small counts that gameplay reads."
|
|
16
|
+
})
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
description: "Runs a particle effect at the entity's transform.",
|
|
20
|
+
requires: [Transform, Visibility]
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
const ParticleEmitterOverrides = defineComponent(
|
|
24
|
+
"particles/ParticleEmitterOverrides",
|
|
25
|
+
{
|
|
26
|
+
emitter: t.string({ description: "Emitter name to change (empty: every emitter)." }),
|
|
27
|
+
spawnRate: t.f32({
|
|
28
|
+
default: -1,
|
|
29
|
+
description: "Particles per second, replacing the effect's rate (negative: the effect's own)."
|
|
30
|
+
}),
|
|
31
|
+
spawnScale: t.f32({
|
|
32
|
+
default: 1,
|
|
33
|
+
min: 0,
|
|
34
|
+
description: "Multiplies the spawn rate (thrust \u2192 exhaust)."
|
|
35
|
+
})
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
description: "Gameplay control of a ParticleSystem: its spawn rate, read every frame.",
|
|
39
|
+
requires: [ParticleSystem]
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
export {
|
|
43
|
+
ParticleEmitterOverrides,
|
|
44
|
+
ParticleSystem
|
|
45
|
+
};
|
package/dist/effect.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { AssetStore } from '@aethervtt/shard-assets';
|
|
2
|
+
import { type AssetRef, ShardError } from '@aethervtt/shard-core';
|
|
3
|
+
import { type Rgba } from './values.js';
|
|
4
|
+
export declare const SHAPES: readonly ['point', 'sphere', 'cone', 'box'];
|
|
5
|
+
export declare const RENDER_MODES: readonly ['billboard', 'stretched', 'axis', 'mesh'];
|
|
6
|
+
export declare const PARTICLE_BLENDS: readonly ['additive', 'alpha', 'premultiplied'];
|
|
7
|
+
export declare const OFFSCREEN: readonly ['simulate', 'pause', 'reduced'];
|
|
8
|
+
/** One emitter of an effect, validated and with defaults filled in. */
|
|
9
|
+
export interface EmitterDef {
|
|
10
|
+
name: string;
|
|
11
|
+
capacity: number;
|
|
12
|
+
spawn: {
|
|
13
|
+
rate: number;
|
|
14
|
+
bursts: {
|
|
15
|
+
time: number;
|
|
16
|
+
count: number;
|
|
17
|
+
interval: number;
|
|
18
|
+
cycles: number;
|
|
19
|
+
}[];
|
|
20
|
+
};
|
|
21
|
+
shape: {
|
|
22
|
+
type: (typeof SHAPES)[number];
|
|
23
|
+
radius: number;
|
|
24
|
+
angle: number;
|
|
25
|
+
size: [number, number, number];
|
|
26
|
+
};
|
|
27
|
+
init: {
|
|
28
|
+
lifetime: [number, number];
|
|
29
|
+
speed: [number, number];
|
|
30
|
+
size: [number, number];
|
|
31
|
+
rotation: [number, number];
|
|
32
|
+
color: [Rgba, Rgba];
|
|
33
|
+
};
|
|
34
|
+
update: Record<string, unknown>[];
|
|
35
|
+
render: {
|
|
36
|
+
mode: (typeof RENDER_MODES)[number];
|
|
37
|
+
blend: (typeof PARTICLE_BLENDS)[number];
|
|
38
|
+
texture: {
|
|
39
|
+
path?: string;
|
|
40
|
+
guid?: string;
|
|
41
|
+
} | null;
|
|
42
|
+
mesh: {
|
|
43
|
+
path?: string;
|
|
44
|
+
guid?: string;
|
|
45
|
+
} | null;
|
|
46
|
+
/** Luminance of color 1 (cd/m²). */
|
|
47
|
+
emissive: number;
|
|
48
|
+
/** Soft particles: fade over this depth distance (m) at intersections (0: off). */
|
|
49
|
+
softness: number;
|
|
50
|
+
/** Stretched mode: length per m/s of speed. */
|
|
51
|
+
stretch: number;
|
|
52
|
+
flipbook: {
|
|
53
|
+
columns: number;
|
|
54
|
+
rows: number;
|
|
55
|
+
fps: number;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
offscreen: (typeof OFFSCREEN)[number];
|
|
59
|
+
/** Authored bounds radius (m) for culling; 0 estimates it from speed and lifetime. */
|
|
60
|
+
bounds: number;
|
|
61
|
+
}
|
|
62
|
+
/** Validates an effect file; every problem comes back with its JSON pointer. */
|
|
63
|
+
export declare function parseEffect(json: unknown): {
|
|
64
|
+
emitters: EmitterDef[];
|
|
65
|
+
errors: ShardError[];
|
|
66
|
+
};
|
|
67
|
+
/** A loaded effect: its emitters, and texture and mesh refs resolved. */
|
|
68
|
+
export declare class ParticleEffect {
|
|
69
|
+
emitters: EmitterDef[];
|
|
70
|
+
/** Bumps on hot reload: running systems keep their particles and pick up the new rules. */
|
|
71
|
+
version: number;
|
|
72
|
+
constructor(emitters: EmitterDef[]);
|
|
73
|
+
static fromJson(json: unknown, resolve?: (path: string) => AssetRef | undefined): ParticleEffect;
|
|
74
|
+
copyFrom(next: ParticleEffect): void;
|
|
75
|
+
}
|
|
76
|
+
export declare class ParticleEffectStore extends AssetStore<ParticleEffect, 'ParticleEffect'> {
|
|
77
|
+
constructor();
|
|
78
|
+
}
|
|
79
|
+
export declare const ParticleEffects: import("@aethervtt/shard-core").ResourceDef<ParticleEffectStore>;
|
|
80
|
+
export declare const ParticleEffectAssetType: import("@aethervtt/shard-assets").AssetTypeDef<ParticleEffect>;
|
|
81
|
+
/** `*.particles.json`: an effect, validated with pointers into the file. */
|
|
82
|
+
export declare const ParticleEffectImporter: import("@aethervtt/shard-assets").ImporterDef;
|
|
83
|
+
/** The effect file's JSON Schema, from the module registry. */
|
|
84
|
+
export declare function particleEffectJsonSchema(): Record<string, unknown>;
|
|
85
|
+
export declare const particleEffectSchema: () => unknown;
|
package/dist/effect.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AssetStore,
|
|
3
|
+
defineAssetSchema,
|
|
4
|
+
defineAssetType,
|
|
5
|
+
defineImporter
|
|
6
|
+
} from "@aethervtt/shard-assets";
|
|
7
|
+
import {
|
|
8
|
+
defineResource,
|
|
9
|
+
defineSchema,
|
|
10
|
+
ShardError
|
|
11
|
+
} from "@aethervtt/shard-core";
|
|
12
|
+
import { MODULES, moduleJsonSchema, validateModule } from "./modules.js";
|
|
13
|
+
import { color, invalid, scalar } from "./values.js";
|
|
14
|
+
const SHAPES = ["point", "sphere", "cone", "box"];
|
|
15
|
+
const RENDER_MODES = ["billboard", "stretched", "axis", "mesh"];
|
|
16
|
+
const PARTICLE_BLENDS = ["additive", "alpha", "premultiplied"];
|
|
17
|
+
const OFFSCREEN = ["simulate", "pause", "reduced"];
|
|
18
|
+
const num = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
19
|
+
function oneOf(values, v, path, errors, fallback) {
|
|
20
|
+
if (v === void 0) return fallback;
|
|
21
|
+
if (values.includes(v)) return v;
|
|
22
|
+
errors.push(invalid(path, `Expected one of: ${values.join(", ")}`));
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
function object(v, path, errors) {
|
|
26
|
+
if (v === void 0) return {};
|
|
27
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return v;
|
|
28
|
+
errors.push(invalid(path, "Expected an object"));
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
function ref(v, path, errors) {
|
|
32
|
+
if (v === void 0 || v === null) return null;
|
|
33
|
+
if (v && typeof v === "object" && (typeof v.path === "string" || typeof v.guid === "string")) {
|
|
34
|
+
return v;
|
|
35
|
+
}
|
|
36
|
+
errors.push(invalid(path, 'Expected an asset reference: { "path": "assets/\u2026" }'));
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
function emitter(json, path, errors, index) {
|
|
40
|
+
const e = object(json, path, errors);
|
|
41
|
+
const spawn = object(e.spawn, `${path}/spawn`, errors);
|
|
42
|
+
const shape = object(e.shape, `${path}/shape`, errors);
|
|
43
|
+
const init = object(e.init, `${path}/init`, errors);
|
|
44
|
+
const render = object(e.render, `${path}/render`, errors);
|
|
45
|
+
const flip = object(render.flipbook, `${path}/render/flipbook`, errors);
|
|
46
|
+
const capacity = num(e.capacity, 1e3);
|
|
47
|
+
if (!Number.isInteger(capacity) || capacity < 1 || capacity > 4194304) {
|
|
48
|
+
errors.push(invalid(`${path}/capacity`, "Capacity must be a whole number from 1 to 4194304"));
|
|
49
|
+
}
|
|
50
|
+
const bursts = Array.isArray(spawn.bursts) ? spawn.bursts : [];
|
|
51
|
+
const update = Array.isArray(e.update) ? e.update : [];
|
|
52
|
+
if (e.update !== void 0 && !Array.isArray(e.update))
|
|
53
|
+
errors.push(invalid(`${path}/update`, "Expected a list of modules"));
|
|
54
|
+
return {
|
|
55
|
+
name: typeof e.name === "string" ? e.name : `emitter${index}`,
|
|
56
|
+
capacity: Math.max(1, Math.floor(capacity)),
|
|
57
|
+
spawn: {
|
|
58
|
+
rate: num(spawn.rate, 0),
|
|
59
|
+
bursts: bursts.map((b, i) => {
|
|
60
|
+
const o = object(b, `${path}/spawn/bursts/${i}`, errors);
|
|
61
|
+
return {
|
|
62
|
+
time: num(o.time, 0),
|
|
63
|
+
count: num(o.count, 10),
|
|
64
|
+
interval: num(o.interval, 0),
|
|
65
|
+
cycles: num(o.cycles, 1)
|
|
66
|
+
};
|
|
67
|
+
})
|
|
68
|
+
},
|
|
69
|
+
shape: {
|
|
70
|
+
type: oneOf(SHAPES, shape.type, `${path}/shape/type`, errors, "point"),
|
|
71
|
+
radius: num(shape.radius, 0),
|
|
72
|
+
angle: num(shape.angle, 25),
|
|
73
|
+
size: Array.isArray(shape.size) ? shape.size : [1, 1, 1]
|
|
74
|
+
},
|
|
75
|
+
init: {
|
|
76
|
+
lifetime: scalar(init.lifetime, `${path}/init/lifetime`, errors, 1),
|
|
77
|
+
speed: scalar(init.speed, `${path}/init/speed`, errors, 1),
|
|
78
|
+
size: scalar(init.size, `${path}/init/size`, errors, 0.1),
|
|
79
|
+
rotation: scalar(init.rotation, `${path}/init/rotation`, errors, 0),
|
|
80
|
+
color: color(init.color, `${path}/init/color`, errors)
|
|
81
|
+
},
|
|
82
|
+
update: update.map((m, i) => validateModule(m, `${path}/update/${i}`, errors)).filter((m) => m !== void 0),
|
|
83
|
+
render: {
|
|
84
|
+
mode: oneOf(RENDER_MODES, render.mode, `${path}/render/mode`, errors, "billboard"),
|
|
85
|
+
blend: oneOf(PARTICLE_BLENDS, render.blend, `${path}/render/blend`, errors, "additive"),
|
|
86
|
+
texture: ref(render.texture, `${path}/render/texture`, errors),
|
|
87
|
+
mesh: ref(render.mesh, `${path}/render/mesh`, errors),
|
|
88
|
+
emissive: num(render.emissive, 1e3),
|
|
89
|
+
softness: num(render.softness, 0),
|
|
90
|
+
stretch: num(render.stretch, 0.05),
|
|
91
|
+
flipbook: { columns: num(flip.columns, 1), rows: num(flip.rows, 1), fps: num(flip.fps, 0) }
|
|
92
|
+
},
|
|
93
|
+
offscreen: oneOf(OFFSCREEN, e.offscreen, `${path}/offscreen`, errors, "simulate"),
|
|
94
|
+
bounds: num(e.bounds, 0)
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function parseEffect(json) {
|
|
98
|
+
const errors = [];
|
|
99
|
+
const root = object(json, "", errors);
|
|
100
|
+
const list = root.emitters;
|
|
101
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
102
|
+
errors.push(
|
|
103
|
+
invalid(
|
|
104
|
+
"/emitters",
|
|
105
|
+
"An effect needs at least one emitter",
|
|
106
|
+
'Add "emitters": [{ "name": "sparks", \u2026 }].'
|
|
107
|
+
)
|
|
108
|
+
);
|
|
109
|
+
return { emitters: [], errors };
|
|
110
|
+
}
|
|
111
|
+
return { emitters: list.map((e, i) => emitter(e, `/emitters/${i}`, errors, i)), errors };
|
|
112
|
+
}
|
|
113
|
+
class ParticleEffect {
|
|
114
|
+
emitters;
|
|
115
|
+
/** Bumps on hot reload: running systems keep their particles and pick up the new rules. */
|
|
116
|
+
version = 0;
|
|
117
|
+
constructor(emitters) {
|
|
118
|
+
this.emitters = emitters;
|
|
119
|
+
}
|
|
120
|
+
static fromJson(json, resolve) {
|
|
121
|
+
const { emitters, errors } = parseEffect(json);
|
|
122
|
+
if (errors.length > 0) {
|
|
123
|
+
const first = errors[0];
|
|
124
|
+
throw new ShardError("particles/invalid-effect", first.message, {
|
|
125
|
+
path: first.path,
|
|
126
|
+
hint: first.hint,
|
|
127
|
+
details: errors
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
for (const e of emitters) {
|
|
131
|
+
if (e.render.texture?.path && resolve)
|
|
132
|
+
e.render.texture = resolve(e.render.texture.path) ?? e.render.texture;
|
|
133
|
+
if (e.render.mesh?.path && resolve)
|
|
134
|
+
e.render.mesh = resolve(e.render.mesh.path) ?? e.render.mesh;
|
|
135
|
+
}
|
|
136
|
+
return new ParticleEffect(emitters);
|
|
137
|
+
}
|
|
138
|
+
copyFrom(next) {
|
|
139
|
+
this.emitters = next.emitters;
|
|
140
|
+
this.version++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
class ParticleEffectStore extends AssetStore {
|
|
144
|
+
constructor() {
|
|
145
|
+
super("ParticleEffect");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const ParticleEffects = defineResource("particles/ParticleEffects", {
|
|
149
|
+
description: "Loaded particle effects by guid.",
|
|
150
|
+
init: () => new ParticleEffectStore()
|
|
151
|
+
});
|
|
152
|
+
const ParticleEffectAssetType = defineAssetType("ParticleEffect", {
|
|
153
|
+
store: ParticleEffects,
|
|
154
|
+
load: (artifact, ctx) => ParticleEffect.fromJson(artifact.json, (p) => ctx.resolve(p)),
|
|
155
|
+
update: (existing, next) => existing.copyFrom(next)
|
|
156
|
+
});
|
|
157
|
+
const NoSettings = defineSchema(
|
|
158
|
+
"particles/NoSettings",
|
|
159
|
+
{},
|
|
160
|
+
{ description: "None: the effect is the file." }
|
|
161
|
+
);
|
|
162
|
+
const ParticleEffectImporter = defineImporter({
|
|
163
|
+
name: "particles",
|
|
164
|
+
version: 1,
|
|
165
|
+
extensions: [".particles.json"],
|
|
166
|
+
settings: NoSettings,
|
|
167
|
+
async import(source) {
|
|
168
|
+
let json;
|
|
169
|
+
try {
|
|
170
|
+
json = JSON.parse(source.text());
|
|
171
|
+
} catch (cause) {
|
|
172
|
+
throw new ShardError("assets/import-failed", `${source.path} isn't valid JSON`, {
|
|
173
|
+
path: source.path,
|
|
174
|
+
cause
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (json && typeof json === "object" && "$schema" in json) {
|
|
178
|
+
const { $schema: _, ...rest } = json;
|
|
179
|
+
json = rest;
|
|
180
|
+
}
|
|
181
|
+
const { emitters, errors } = parseEffect(json);
|
|
182
|
+
if (errors.length > 0) {
|
|
183
|
+
const first = errors[0];
|
|
184
|
+
throw new ShardError(
|
|
185
|
+
"assets/import-failed",
|
|
186
|
+
`${source.path}: ${first.message}${errors.length > 1 ? ` (+${errors.length - 1} more)` : ""}`,
|
|
187
|
+
{ path: first.path, hint: first.hint, details: errors }
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const dependencies = /* @__PURE__ */ new Set();
|
|
191
|
+
for (const e of emitters) {
|
|
192
|
+
if (e.render.texture?.path) dependencies.add(e.render.texture.path);
|
|
193
|
+
if (e.render.mesh?.path && !e.render.mesh.path.startsWith("procedural:"))
|
|
194
|
+
dependencies.add(e.render.mesh.path);
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
assets: [
|
|
198
|
+
{
|
|
199
|
+
label: "",
|
|
200
|
+
type: "ParticleEffect",
|
|
201
|
+
json,
|
|
202
|
+
...dependencies.size ? { dependencies: [...dependencies] } : {},
|
|
203
|
+
info: {
|
|
204
|
+
emitters: emitters.length,
|
|
205
|
+
capacity: emitters.reduce((n, e) => n + e.capacity, 0)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
function particleEffectJsonSchema() {
|
|
213
|
+
const scalar2 = {
|
|
214
|
+
anyOf: [
|
|
215
|
+
{ type: "number" },
|
|
216
|
+
{ type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 }
|
|
217
|
+
],
|
|
218
|
+
description: "A number, or a [min, max] range picked per particle."
|
|
219
|
+
};
|
|
220
|
+
const colorForm = {
|
|
221
|
+
anyOf: [
|
|
222
|
+
{ type: "string", pattern: "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" },
|
|
223
|
+
{ type: "array", items: { type: "number" }, minItems: 3, maxItems: 4 },
|
|
224
|
+
{ type: "array", minItems: 2, maxItems: 2 }
|
|
225
|
+
],
|
|
226
|
+
description: '"#rrggbb" (sRGB), [r, g, b, a] (linear), or a range of two colors.'
|
|
227
|
+
};
|
|
228
|
+
const ref2 = { type: "object", properties: { path: { type: "string" } }, required: ["path"] };
|
|
229
|
+
return {
|
|
230
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
231
|
+
title: "Particle effect (*.particles.json)",
|
|
232
|
+
type: "object",
|
|
233
|
+
properties: {
|
|
234
|
+
$schema: { type: "string" },
|
|
235
|
+
emitters: {
|
|
236
|
+
type: "array",
|
|
237
|
+
minItems: 1,
|
|
238
|
+
items: {
|
|
239
|
+
type: "object",
|
|
240
|
+
properties: {
|
|
241
|
+
name: { type: "string" },
|
|
242
|
+
capacity: { type: "integer", minimum: 1, description: "Most particles alive at once." },
|
|
243
|
+
spawn: {
|
|
244
|
+
type: "object",
|
|
245
|
+
properties: {
|
|
246
|
+
rate: { type: "number", description: "Particles per second." },
|
|
247
|
+
bursts: {
|
|
248
|
+
type: "array",
|
|
249
|
+
items: {
|
|
250
|
+
type: "object",
|
|
251
|
+
properties: {
|
|
252
|
+
time: { type: "number" },
|
|
253
|
+
count: { type: "number" },
|
|
254
|
+
interval: { type: "number", description: "Seconds between repeats." },
|
|
255
|
+
cycles: { type: "number", description: "Repeats (0: forever)." }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
shape: {
|
|
262
|
+
type: "object",
|
|
263
|
+
properties: {
|
|
264
|
+
type: { enum: SHAPES },
|
|
265
|
+
radius: { type: "number" },
|
|
266
|
+
angle: { type: "number", description: "Cone half-angle, degrees." },
|
|
267
|
+
size: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
init: {
|
|
271
|
+
type: "object",
|
|
272
|
+
properties: {
|
|
273
|
+
lifetime: scalar2,
|
|
274
|
+
speed: scalar2,
|
|
275
|
+
size: scalar2,
|
|
276
|
+
rotation: scalar2,
|
|
277
|
+
color: colorForm
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
update: {
|
|
281
|
+
type: "array",
|
|
282
|
+
items: { oneOf: Object.values(MODULES).map(moduleJsonSchema) }
|
|
283
|
+
},
|
|
284
|
+
render: {
|
|
285
|
+
type: "object",
|
|
286
|
+
properties: {
|
|
287
|
+
mode: { enum: RENDER_MODES },
|
|
288
|
+
blend: { enum: PARTICLE_BLENDS },
|
|
289
|
+
texture: ref2,
|
|
290
|
+
mesh: ref2,
|
|
291
|
+
emissive: { type: "number", description: "cd/m\xB2 of color 1." },
|
|
292
|
+
softness: {
|
|
293
|
+
type: "number",
|
|
294
|
+
description: "Depth fade distance (m) at intersections."
|
|
295
|
+
},
|
|
296
|
+
stretch: { type: "number" },
|
|
297
|
+
flipbook: {
|
|
298
|
+
type: "object",
|
|
299
|
+
properties: {
|
|
300
|
+
columns: { type: "number" },
|
|
301
|
+
rows: { type: "number" },
|
|
302
|
+
fps: { type: "number" }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
offscreen: { enum: OFFSCREEN },
|
|
308
|
+
bounds: { type: "number" }
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
required: ["emitters"]
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const particleEffectSchema = defineAssetSchema(
|
|
317
|
+
"particle-effect.schema.json",
|
|
318
|
+
particleEffectJsonSchema
|
|
319
|
+
);
|
|
320
|
+
export {
|
|
321
|
+
OFFSCREEN,
|
|
322
|
+
PARTICLE_BLENDS,
|
|
323
|
+
ParticleEffect,
|
|
324
|
+
ParticleEffectAssetType,
|
|
325
|
+
ParticleEffectImporter,
|
|
326
|
+
ParticleEffectStore,
|
|
327
|
+
ParticleEffects,
|
|
328
|
+
RENDER_MODES,
|
|
329
|
+
SHAPES,
|
|
330
|
+
parseEffect,
|
|
331
|
+
particleEffectJsonSchema,
|
|
332
|
+
particleEffectSchema
|
|
333
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { ParticleEmitterOverrides, ParticleSystem } from './components.js';
|
|
2
|
+
export { type EmitterDef, OFFSCREEN, PARTICLE_BLENDS, ParticleEffect, ParticleEffectAssetType, ParticleEffectImporter, ParticleEffectStore, ParticleEffects, parseEffect, particleEffectJsonSchema, RENDER_MODES, SHAPES, } from './effect.js';
|
|
3
|
+
export { MODULES, type ModuleDef } from './modules.js';
|
|
4
|
+
export { particlesPlugin } from './plugin.js';
|
|
5
|
+
export { PARTICLE_FLOATS, renderShader, simulationShader } from './shaders.js';
|
|
6
|
+
export { describeParticles, type EmitterState, MAX_SORTED, ParticleStore, Particles, prepareParticles, readParticles, type SystemState, shiftParticles, } from './sim.js';
|
|
7
|
+
export { pcg, rand } from './values.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ParticleEmitterOverrides, ParticleSystem } from "./components.js";
|
|
2
|
+
import {
|
|
3
|
+
OFFSCREEN,
|
|
4
|
+
PARTICLE_BLENDS,
|
|
5
|
+
ParticleEffect,
|
|
6
|
+
ParticleEffectAssetType,
|
|
7
|
+
ParticleEffectImporter,
|
|
8
|
+
ParticleEffectStore,
|
|
9
|
+
ParticleEffects,
|
|
10
|
+
parseEffect,
|
|
11
|
+
particleEffectJsonSchema,
|
|
12
|
+
RENDER_MODES,
|
|
13
|
+
SHAPES
|
|
14
|
+
} from "./effect.js";
|
|
15
|
+
import { MODULES } from "./modules.js";
|
|
16
|
+
import { particlesPlugin } from "./plugin.js";
|
|
17
|
+
import { PARTICLE_FLOATS, renderShader, simulationShader } from "./shaders.js";
|
|
18
|
+
import {
|
|
19
|
+
describeParticles,
|
|
20
|
+
MAX_SORTED,
|
|
21
|
+
ParticleStore,
|
|
22
|
+
Particles,
|
|
23
|
+
prepareParticles,
|
|
24
|
+
readParticles,
|
|
25
|
+
shiftParticles
|
|
26
|
+
} from "./sim.js";
|
|
27
|
+
import { pcg, rand } from "./values.js";
|
|
28
|
+
export {
|
|
29
|
+
MAX_SORTED,
|
|
30
|
+
MODULES,
|
|
31
|
+
OFFSCREEN,
|
|
32
|
+
PARTICLE_BLENDS,
|
|
33
|
+
PARTICLE_FLOATS,
|
|
34
|
+
ParticleEffect,
|
|
35
|
+
ParticleEffectAssetType,
|
|
36
|
+
ParticleEffectImporter,
|
|
37
|
+
ParticleEffectStore,
|
|
38
|
+
ParticleEffects,
|
|
39
|
+
ParticleEmitterOverrides,
|
|
40
|
+
ParticleStore,
|
|
41
|
+
ParticleSystem,
|
|
42
|
+
Particles,
|
|
43
|
+
RENDER_MODES,
|
|
44
|
+
SHAPES,
|
|
45
|
+
describeParticles,
|
|
46
|
+
parseEffect,
|
|
47
|
+
particleEffectJsonSchema,
|
|
48
|
+
particlesPlugin,
|
|
49
|
+
pcg,
|
|
50
|
+
prepareParticles,
|
|
51
|
+
rand,
|
|
52
|
+
readParticles,
|
|
53
|
+
renderShader,
|
|
54
|
+
shiftParticles,
|
|
55
|
+
simulationShader
|
|
56
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ShardError } from '@aethervtt/shard-core';
|
|
2
|
+
/** A module parameter: its form, default, and what it means (for the published schema). */
|
|
3
|
+
export interface ParamDef {
|
|
4
|
+
kind: 'number' | 'scalar' | 'vec3' | 'curve' | 'gradient' | 'enum';
|
|
5
|
+
default?: unknown;
|
|
6
|
+
values?: readonly string[];
|
|
7
|
+
description: string;
|
|
8
|
+
}
|
|
9
|
+
/** A particle's state as the CPU backend holds it (one particle, reused). */
|
|
10
|
+
export interface CpuParticle {
|
|
11
|
+
pos: Float32Array;
|
|
12
|
+
vel: Float32Array;
|
|
13
|
+
age: number;
|
|
14
|
+
life: number;
|
|
15
|
+
rot: number;
|
|
16
|
+
rotSpeed: number;
|
|
17
|
+
seed: number;
|
|
18
|
+
}
|
|
19
|
+
export interface ModuleDef {
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
params: Record<string, ParamDef>;
|
|
23
|
+
/** WGSL run once at spawn, after the init values (can set rot_speed). */
|
|
24
|
+
init?(p: Record<string, unknown>): string;
|
|
25
|
+
/** WGSL run every step for a live particle: pos, vel, age, life, rot, rot_speed, seed, dt. */
|
|
26
|
+
update?(p: Record<string, unknown>, id: string): string;
|
|
27
|
+
/** Helper functions the update or render code calls (curves, gradients). */
|
|
28
|
+
functions?(p: Record<string, unknown>, id: string): string;
|
|
29
|
+
/** WGSL in the vertex stage: t (life fraction), color, size. */
|
|
30
|
+
render?(p: Record<string, unknown>, id: string): string;
|
|
31
|
+
/** The CPU backend's version of `init` and `update`. */
|
|
32
|
+
cpuInit?(p: Record<string, unknown>, s: CpuParticle): void;
|
|
33
|
+
cpu?(p: Record<string, unknown>, s: CpuParticle, dt: number, time: number): void;
|
|
34
|
+
/** Needs the view's depth buffer (collision). */
|
|
35
|
+
depth?: boolean;
|
|
36
|
+
}
|
|
37
|
+
export declare const MODULES: Record<string, ModuleDef>;
|
|
38
|
+
/** Validates a module entry in place (filling defaults); pushes errors with pointers. */
|
|
39
|
+
export declare function validateModule(json: unknown, path: string, errors: ShardError[]): Record<string, unknown> | undefined;
|
|
40
|
+
/** JSON Schema of a module entry, from its parameter definitions. */
|
|
41
|
+
export declare function moduleJsonSchema(def: ModuleDef): Record<string, unknown>;
|