1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
//! Authored particle emitter definitions.
use super::primitive::TextureRef;
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[derive(Copy)]
pub enum SpawnShapeDef {
Point,
Sphere {
radius: f32,
},
Cone {
angle_radians: f32,
/// Spawn direction in the emitter's **local** space (rotated by the
/// node's transform). Default `[0,1,0]` shoots particles up the local +Y.
direction: [f32; 3],
},
}
impl SpawnShapeDef {
pub fn default_cone() -> Self {
// Default direction +Y so a fresh emitter shoots particles
// upward (smoke / sparks / steam — the typical case). The
// earlier -Y default ran particles through the ground plane
// and they were invisible to a fresh user.
Self::Cone {
angle_radians: 0.4,
direction: [0.0, 1.0, 0.0],
}
}
}
impl Default for SpawnShapeDef {
fn default() -> Self {
Self::default_cone()
}
}
/// A per-frame force applied to live particles. Serializes externally-tagged:
/// `{"gravity": {"acceleration": [x,y,z]}}` or
/// `{"linear_drag": {"coefficient_x1000": <u32>}}` (drag coefficient ×1000, so
/// `500` = 0.5/s). World-space acceleration.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[derive(Copy)]
pub enum ForceDef {
Gravity { acceleration: [f32; 3] },
LinearDrag { coefficient_x1000: u32 },
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[derive(Eq, Hash, Copy, Default)]
pub enum EmitterSpaceDef {
/// Particles persist in world space.
World,
/// Particles follow the emitter transform.
#[default]
Local,
}
/// Externally-tagged: `{"const": [r,g,b,a]}` or
/// `{"linear": {"start": [r,g,b,a], "end": [r,g,b,a]}}`. Alpha is the only
/// transparency knob (see `ParticleEmitterDef::color_over_life`).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ColorOverLifeDef {
Const([f32; 4]),
Linear { start: [f32; 4], end: [f32; 4] },
}
/// Externally-tagged: `{"const": <f32>}` or `{"linear": {"start": <f32>, "end": <f32>}}`.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[derive(Copy)]
pub enum SizeOverLifeDef {
Const(f32),
Linear { start: f32, end: f32 },
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ParticleEmitterDef {
pub spawn_rate: f32,
pub burst_count: u32,
pub max_alive: u32,
pub one_shot: bool,
pub space: EmitterSpaceDef,
pub shape: SpawnShapeDef,
pub initial_speed: [f32; 2],
pub lifetime: [f32; 2],
pub size: [f32; 2],
pub forces: Vec<ForceDef>,
/// Per-particle RGBA curve over lifetime. The alpha channel is
/// the *only* transparency knob — there used to be a separate
/// `alpha_over_life: AlphaOverLifeDef` field, but it just
/// multiplied with this `.a` and trivially produced α² fades
/// when the user set both to 1→0. The schema no longer carries
/// it; the fragment shader multiplies the texture's alpha by
/// this color's `.a` and the per-instance attr alpha and that's
/// the visible transparency.
pub color_over_life: ColorOverLifeDef,
pub size_over_life: SizeOverLifeDef,
/// Optional sprite texture for billboard rendering.
pub texture: Option<TextureRef>,
/// Route this emitter through the transparent-blend pass instead of the
/// opaque-emissive path. Required for true alpha-fading particles
/// (smoke, soft glows). Opaque is the default since the visibility
/// buffer is cheaper.
#[serde(default)]
pub blend: bool,
}
impl Default for ParticleEmitterDef {
fn default() -> Self {
Self {
spawn_rate: 60.0,
burst_count: 0,
max_alive: 256,
one_shot: false,
space: EmitterSpaceDef::Local,
shape: SpawnShapeDef::default(),
initial_speed: [1.0, 2.0],
// A 2s upper bound on lifetime keeps a fresh smoke / steam
// emitter visible long enough to read the curve falloff —
// 0.8s was too short for the particles to register before
// they faded.
lifetime: [0.4, 2.0],
size: [0.1, 0.2],
forces: vec![],
// Default is a neutral white→white fade so a newly
// inserted emitter with no texture renders as plain
// dots, and the user's first texture binding shows up
// un-tinted. (The old fiery orange→red default made
// every fresh smoke emitter look like fire.)
color_over_life: ColorOverLifeDef::Linear {
start: [1.0, 1.0, 1.0, 1.0],
end: [1.0, 1.0, 1.0, 0.0],
},
size_over_life: SizeOverLifeDef::Linear {
start: 1.0,
end: 0.3,
},
texture: None,
blend: false,
}
}
}