Particle System Editor

Overview

I made this particle system for my school group’s game engine. There was no way for our procedural artist to create particles in our school’s game engine, so I made this completely from scratch. When creating this tool I took inspiration from Unity’s particle system when deciding what to add. During the development of Spite: The Curse of Tzalozel I would add more features, depending on what we needed for our game.

Shape & Emission

All particles always spawn with a set lifetime. This value can be the same for every particle, or randomly generated at spawn. Every property in the particle system that can be random has options to choose between a fixed Value, a random value in a Range or a randomly chosen value of Either left or right value.

white kitten

To spawn the particles I have settings for when to spawn them in Emission and where to spawn them in Shape. In emission, you can edit how many particles to spawn per second, how many per distance (Used to spawn when physically moving the particle system) and bursts. For burst emissions you can set how many burst in total, how many particles in every burst and the time between every burst.

If Emit World is checked particles will move independently once spawned. Otherwise they will move locally to the transform of the particle system.

white kitten

In Shape, you can choose between emitting in a sphere, cone or box. There are more settings for every shape type. The cone has settings for radius and height, and an arc setting that controls how much the cone’s circular base is used for emission. You can also control thickness, which when smaller will make particles spawn more to the edge of the shape.

The direction the particles spawn with is determined by their spawn position and the emitter’s shape type At the request of our procedural artists, I also added an option to spawn particles with a fixed direction.

white kitten

Speed, Size & Rotation

Speed, size and rotation can all be edited for both start values and over time change. For the over time values, I added an option to make a curve for the value. The curve can have points moved, added and removed. You can also choose if interpolation between points is linear or hermite.

white kitten

This is the code for the Curve, both how it displays in editor and how it evaluates a value:

  1namespace Goose
  2{
  3	struct CurvePoint
  4	{
  5		float value   = 0.f;
  6		float tangent = 0.f;
  7		float time    = 0.f;
  8	};
  9
 10	class FunctionCurve
 11	{
 12	public:
 13		float Evaluate(float aTime) const;
 14
 15		std::vector<CurvePoint>& AccessPoints();
 16		const std::vector<CurvePoint>& AccessPoints() const;
 17
 18		bool IsLinear() const;
 19		void SetIsLinear(bool aLinear);
 20
 21		uint8_t DisplayImGuiEdit(const char* aId);
 22	protected:
 23		void Sort();
 24		uint8_t DisplayCurveEdit();
 25
 26		std::vector<CurvePoint> myPoints{{}, {.value = 1.f, .time = 1.f}};
 27		float myMinValue{ 0.f };
 28		float myMaxValue{ 1.f };
 29		bool myIsLinear = false;
 30	};
 31}
 32
 33
 34static float HermiteInterpolate(const Goose::CurvePoint& k0, const Goose::CurvePoint& k1, const float t)
 35{
 36	const float dt = k1.time - k0.time;
 37	const float m0 = -k0.tangent * dt;
 38	const float m1 = -k1.tangent * dt;
 39	const float u  = (t - k0.time) / dt;
 40
 41	const float u2 = u * u;
 42	const float u3 = u2 * u;
 43
 44	// Cubic Hermite spline basis
 45	const float h00 = 2 * u3 - 3 * u2 + 1;
 46	const float h10 = u3 - 2 * u2 + u;
 47	const float h01 = -2 * u3 + 3 * u2;
 48	const float h11 = u3 - u2;
 49
 50	const float result = h00 * k0.value + h10 * m0 + h01 * k1.value + h11 * m1;
 51	return result;
 52}
 53
 54float Goose::FunctionCurve::Evaluate(const float aTime) const
 55{
 56	if (aTime <= myPoints.front().time)
 57	{
 58		return myPoints.front().value;
 59	}
 60	if (aTime >= myPoints.back().time)
 61	{
 62		return myPoints.back().value;
 63	}
 64
 65	CurvePoint prevCurvePoint = myPoints.front();
 66	float returnValue{};
 67
 68	for (const CurvePoint& curvePoint : myPoints)
 69	{
 70		returnValue = curvePoint.value;
 71		if (curvePoint.time > aTime)
 72		{
 73			if (aTime == 0.f)
 74			{
 75				break;
 76			}
 77
 78			if (myIsLinear)
 79			{
 80				const float deltaT = aTime - prevCurvePoint.time;
 81				returnValue = prevCurvePoint.value + ((curvePoint.value - prevCurvePoint.value) / (curvePoint.time - prevCurvePoint.time)) *
 82				              deltaT;
 83			}
 84			else
 85			{
 86				returnValue = HermiteInterpolate(prevCurvePoint, curvePoint, aTime);
 87			}
 88			break;
 89		}
 90		prevCurvePoint = curvePoint;
 91	}
 92
 93	return returnValue;
 94}
 95
 96std::vector<Goose::CurvePoint>& Goose::FunctionCurve::AccessPoints()
 97{
 98	return myPoints;
 99}
100
101const std::vector<Goose::CurvePoint>& Goose::FunctionCurve::AccessPoints() const
102{
103	return myPoints;
104}
105
106bool Goose::FunctionCurve::IsLinear() const
107{
108	return myIsLinear;
109}
110
111void Goose::FunctionCurve::SetIsLinear(const bool aLinear)
112{
113	myIsLinear = aLinear;
114}
115
116#pragma region ImGuiSection
117
118constexpr ImU32 BACKGROUND_COLOR         = IM_COL32(130, 130, 130, 255);
119constexpr ImU32 GRAPH_COLOR              = IM_COL32(30, 230, 30, 255);
120constexpr ImU32 POINT_COLOR              = IM_COL32(60, 190, 60, 255);
121constexpr ImU32 POINT_HOVER_COLOR        = IM_COL32(80, 200, 80, 255);
122constexpr ImU32 POINT_DELETE_COLOR       = IM_COL32(190, 40, 40, 255);
123constexpr ImU32 POINT_DELETE_HOVER_COLOR = IM_COL32(210, 60, 60, 255);
124constexpr ImU32 TANGENT_COLOR            = IM_COL32(60, 160, 120, 255);
125constexpr ImU32 TANGENT_HOVER_COLOR      = IM_COL32(800, 180, 180, 255);
126
127constexpr char CURVE_POP_UP[] = "Curve Popup";
128
129constexpr float MAX_TANGENT = 320.f;
130
131struct CurveImGuiInfo
132{
133	ImVec2 myMouseTracking;
134	int mySelectedPoint = -1;
135	bool myDragTangent  = false;
136};
137
138static CurveImGuiInfo localInfo;
139
140uint8_t Goose::FunctionCurve::DisplayImGuiEdit(const char* aId)
141{
142	ImGui::PushID(aId);
143
144	if (globalIsUsingNodeEditor) // This does not display correctly in node editor and is disabled
145	{
146		ImGui::Text("_Variable_Only_");
147		return 0u;
148	}
149	
150	constexpr float spacingX = 10.f;
151	constexpr float graphHeight = 30.f;
152
153	const ImVec2 origin = ImGui::GetCursorScreenPos();
154	const float totalWidth = ImGui::GetContentRegionAvail().x * 0.7f - spacingX * 2.f;
155
156	const ImVec2 graphSize = ImVec2(totalWidth, graphHeight);
157
158	ImDrawList* drawList = ImGui::GetWindowDrawList();
159
160	drawList->AddRectFilled(origin, origin + graphSize, BACKGROUND_COLOR);
161
162	for (size_t i = 0; i + 1 < myPoints.size(); ++i)
163	{
164		ImVec2 point1 = origin + ImVec2(totalWidth * myPoints[i].time,
165			FMath::InverseLerp(myMaxValue, myMinValue, myPoints[i].value) * graphHeight);
166		ImVec2 point2 = origin + ImVec2(totalWidth * myPoints[i + 1].time,
167			FMath::InverseLerp(myMaxValue, myMinValue, myPoints[i + 1].value) * graphHeight);
168		drawList->AddLine(point1, point2, GRAPH_COLOR, 1.f);
169	}
170
171	ImGui::Dummy(graphSize);
172
173	
174
175	const bool hovered = ImGui::IsItemHovered();
176	if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left))
177	{
178		ImGui::OpenPopup(CURVE_POP_UP);
179		ImGui::SetNextWindowPos(ImGui::GetCursorScreenPos());
180		localInfo = CurveImGuiInfo();
181	}
182	uint8_t info = 0;
183	/*ImGui::SetNextWindowFocus();*/
184	if (ImGui::BeginPopup(CURVE_POP_UP, ImGuiWindowFlags_NoMove))
185	{
186		info |= DisplayCurveEdit();
187		ImGui::EndPopup();
188	}
189
190	ImGui::PopID();
191
192	return info;
193}
194
195void Goose::FunctionCurve::Sort()
196{
197	for (int i = 0; i < static_cast<int>(myPoints.size()); i++)
198	{
199		int bestIndex    = i;
200		float lowestTime = myPoints[i].time;
201		for (int j = i + 1; j < static_cast<int>(myPoints.size()); ++j)
202		{
203			if (myPoints[j].time < lowestTime)
204			{
205				bestIndex  = j;
206				lowestTime = myPoints[j].time;
207			}
208		}
209
210		std::swap(myPoints[i], myPoints[bestIndex]);
211		if (bestIndex == localInfo.mySelectedPoint)
212		{
213			localInfo.mySelectedPoint = i;
214		}
215		else if (i == localInfo.mySelectedPoint)
216		{
217			localInfo.mySelectedPoint = bestIndex;
218		}
219	}
220}
221
222uint8_t Goose::FunctionCurve::DisplayCurveEdit()
223{
224	constexpr ImVec2 graphOffset    = ImVec2(20, 20);
225	constexpr ImVec2 graphSize      = {300.f, 100.f};
226	constexpr int graphCurveDetail  = 200;
227	constexpr float pointSize       = 5.f;
228	constexpr float tangentDistance = pointSize * 4.f;
229
230	constexpr float sideItemsWidth = 50.f;
231	constexpr float sideOffset     = 20.f;
232
233	constexpr ImVec2 totalSize = {sideItemsWidth + sideItemsWidth + graphSize.x + graphOffset.x * 2.f, graphSize.y + graphOffset.y * 2.f};
234
235	const ImVec2 origin           = ImGui::GetWindowPos() + graphOffset;
236	const ImVec2 sideCursorOrigin = graphOffset + ImVec2(graphSize.x + sideOffset, 0.f);
237
238	const bool deleteMode = ImGui::GetIO().KeyShift && myPoints.size() > 2;
239
240	uint8_t info = 0;
241
242	//Drawing (and deleting)
243	bool hasHovered = false;
244	{
245		ImDrawList* drawList = ImGui::GetWindowDrawList();
246
247		drawList->AddRectFilled(origin, origin + graphSize, BACKGROUND_COLOR);
248
249		if (myIsLinear)
250		{
251			for (size_t i = 0; i + 1 < myPoints.size(); ++i)
252			{
253				ImVec2 point1 = origin + ImVec2(graphSize.x * myPoints[i].time,
254				                                FMath::InverseLerp(myMaxValue, myMinValue, myPoints[i].value) *
255				                                graphSize.y);
256				ImVec2 point2 = origin + ImVec2(graphSize.x * myPoints[i + 1].time,
257				                                FMath::InverseLerp(myMaxValue, myMinValue,
258				                                                   myPoints[i + 1].value) * graphSize.y);
259				drawList->AddLine(point1, point2, GRAPH_COLOR, 2.f);
260			}
261
262			const float firstY = FMath::InverseLerp(myMaxValue, myMinValue, myPoints.front().value) * graphSize.
263			                     y;
264			const float lastY = FMath::InverseLerp(myMaxValue, myMinValue, myPoints.back().value) * graphSize.y;
265
266			drawList->AddLine(origin + ImVec2(0.f, firstY), origin + ImVec2(myPoints.front().time * graphSize.x, firstY), GRAPH_COLOR, 2.f);
267			drawList->AddLine(origin + ImVec2(graphSize.x, lastY), origin + ImVec2(myPoints.back().time * graphSize.x, lastY), GRAPH_COLOR,
268			                  2.f);
269		}
270		else
271		{
272			ImVec2 points[graphCurveDetail]{};
273
274			constexpr float timeIncrement = 1.f / static_cast<float>(graphCurveDetail);
275			float t                       = timeIncrement;
276
277			for (int i = 0; i < graphCurveDetail; ++i)
278			{
279				points[i] = origin + ImVec2{
280					            t,
281					            FMath::InverseLerp(myMaxValue, myMinValue, Evaluate(t))
282				            } * graphSize;
283				t += timeIncrement;
284			}
285
286			drawList->AddPolyline(points, graphCurveDetail, GRAPH_COLOR, ImDrawFlags_None, 2.f);
287		}
288
289		const ImU32 defaultColor = deleteMode ? POINT_DELETE_COLOR : POINT_COLOR;
290		const ImU32 hoverColor   = deleteMode ? POINT_DELETE_HOVER_COLOR : POINT_HOVER_COLOR;
291
292		for (int i = 0; i < static_cast<int>(myPoints.size()); ++i)
293		{
294			CurvePoint& point = myPoints[i];
295
296			ImVec2 drawPosition = origin + ImVec2(point.time, FMath::InverseLerp(myMaxValue, myMinValue,
297			                                                                     point.value)) * graphSize;
298
299			const bool selected = localInfo.mySelectedPoint == i;
300			bool hovered        = false;
301			if (hasHovered == false && ImGuiUtil::IsMouseOverCircle(drawPosition, pointSize))
302			{
303				hovered    = true;
304				hasHovered = true;
305			}
306
307			drawList->AddCircleFilled(drawPosition, pointSize, hovered ? hoverColor : defaultColor);
308
309			if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left))
310			{
311				if (deleteMode)
312				{
313					localInfo.mySelectedPoint = -1;
314					myPoints.erase(myPoints.begin() + i);
315					break;
316				}
317				localInfo.mySelectedPoint = i;
318				localInfo.myMouseTracking = drawPosition;
319				localInfo.myDragTangent   = false;
320			}
321
322			if (selected && myIsLinear == false)
323			{
324				const float angle = atanf(point.tangent);
325
326				const ImVec2 tangentVector = {std::cosf(angle), std::sinf(angle)};
327
328				ImVec2 tangentDrawPos = drawPosition + tangentVector * tangentDistance;
329
330				const bool tangentHovered = ImGuiUtil::IsMouseOverCircle(tangentDrawPos, pointSize);
331				hasHovered |= tangentHovered;
332
333				drawList->AddCircleFilled(tangentDrawPos, pointSize, tangentHovered ? TANGENT_HOVER_COLOR : TANGENT_COLOR);
334
335				if (tangentHovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left))
336				{
337					localInfo.myMouseTracking = tangentDrawPos;
338					localInfo.myDragTangent   = true;
339				}
340			}
341		}
342	}
343
344	// Edit Points
345	{
346		if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && hasHovered == false)
347		{
348			if (localInfo.mySelectedPoint != -1)
349			{
350				info |= PropertyEdit::InfoFlag_EditEnded | PropertyEdit::InfoFlag_ChangedValue;
351			}
352
353			localInfo.mySelectedPoint = -1;
354		}
355
356		localInfo.myMouseTracking += ImGui::GetIO().MouseDelta;
357
358		if (localInfo.mySelectedPoint != -1 && ImGui::IsMouseDown(ImGuiMouseButton_Left))
359		{
360			if ((ImGui::GetIO().MouseDelta.x != 0.f) || (ImGui::GetIO().MouseDelta.y != 0.f))
361			{
362				info |= PropertyEdit::InfoFlag_ChangedValue;
363			}
364
365			if (localInfo.myDragTangent)
366			{
367				const ImVec2 pointPosition = origin + ImVec2(myPoints[localInfo.mySelectedPoint].time,
368				                                             FMath::InverseLerp(myMaxValue, myMinValue,
369				                                                                myPoints[localInfo.mySelectedPoint].value)) * graphSize;
370
371				ImVec2 directionVector = localInfo.myMouseTracking - pointPosition;
372				directionVector /= sqrtf(directionVector.x * directionVector.x + directionVector.y * directionVector.y);
373				const float angle     = FMath::Clamp(acosf(directionVector.x), -FMath::Pi_Almost_Half, FMath::Pi_Almost_Half);
374				const float angleSign = directionVector.y < 0.f ? -1.f : 1.f;
375
376				myPoints[localInfo.mySelectedPoint].tangent = tanf(angle * angleSign);
377			}
378			else
379			{
380				myPoints[localInfo.mySelectedPoint].time = FMath::Clamp(FMath::InverseLerp(origin.x, origin.x + graphSize.x,
381				                                                                           localInfo.myMouseTracking.x), 0.f, 1.f);
382				myPoints[localInfo.mySelectedPoint].value = FMath::Lerp(myMinValue, myMaxValue,
383				                                                        FMath::Clamp(FMath::InverseLerp(origin.y + graphSize.y, origin.y,
384				                                                                      localInfo.myMouseTracking.y), 0.f, 1.f));
385
386				Sort();
387			}
388		}
389
390		if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && localInfo.mySelectedPoint == -1 && hasHovered == false)
391		{
392			constexpr float tolerance = 6.f;
393
394			const float mouseT = FMath::InverseLerp(origin.x, origin.x + graphSize.x, ImGui::GetMousePos().x);
395
396			if (mouseT >= 0.f && mouseT <= 1.f)
397			{
398				const float mouseY = ImGui::GetMousePos().y;
399
400				const float graphValue = Evaluate(mouseT);
401				const float graphY = origin.y + FMath::InverseLerp(myMaxValue, myMinValue, Evaluate(mouseT)) *
402				                     graphSize.y;
403
404				if (fabsf(mouseY - graphY) < tolerance)
405				{
406					CurvePoint newPoint;
407					newPoint.time    = mouseT;
408					newPoint.tangent = 0.f;
409					newPoint.value   = FMath::Clamp(graphValue, myMinValue, myMaxValue);
410
411					myPoints.emplace_back(newPoint);
412
413					Sort();
414
415					info |= PropertyEdit::InfoFlag_EditEnded | PropertyEdit::InfoFlag_ChangedValue;
416				}
417			}
418		}
419	}
420
421	// Side items
422	{
423		const float initialMax = myMaxValue;
424		const float initialMin = myMinValue;
425
426		ImGui::PushItemWidth(sideItemsWidth);
427
428		ImGui::SetCursorPos(sideCursorOrigin);
429		info |= ImGuiUtil::DisplayDragFloat("##MaxValue", &myMaxValue, 0.01f);
430		if (ImGui::IsItemHovered())
431		{
432			ImGui::SetTooltip("Max");
433		}
434
435		ImGui::SetCursorPos(ImVec2(sideCursorOrigin.x, ImGui::GetCursorPos().y));
436		info |= ImGuiUtil::DisplayDragFloat("##MinValue", &myMinValue, 0.01f);
437		if (ImGui::IsItemHovered())
438		{
439			ImGui::SetTooltip("Min");
440		}
441
442		ImGui::PopItemWidth();
443
444		if (myMinValue != initialMin || myMaxValue != initialMax)
445		{
446			if (myMinValue >= myMaxValue)
447			{
448				myMinValue = initialMin;
449				myMaxValue = initialMax;
450			}
451			else
452			{
453				info |= PropertyEdit::InfoFlag_ChangedValue;
454
455				for (auto& point : myPoints)
456				{
457					point.value = FMath::Remap(point.value, initialMin, initialMax, myMinValue, myMaxValue);
458				}
459			}
460		}
461
462		ImGui::SetCursorPos(ImVec2(sideCursorOrigin.x, ImGui::GetCursorPos().y));
463		if (ImGui::Checkbox("Linear", &myIsLinear))
464		{
465			info |= PropertyEdit::InfoFlag_EditEnded | PropertyEdit::InfoFlag_ChangedValue;
466		}
467	}
468
469	ImGui::SetCursorPos({0.f, 0.f});
470	ImGui::Dummy(totalSize);
471
472	return info;
473}
474
475#pragma endregion

Color

Particle color can also be edited for both start values and over time change, both using a gradient. The start value will be a random color in the first gradient, and the second gradient is an over-time multiplier.

white kitten

The gradient property was also custom made and has similar code to the curve:

  1
  2namespace Goose
  3{
  4	struct ColorKey
  5	{
  6		Tga::Color color = {1.f, 1.f, 1.f, 1.f};
  7		float time       = {0.f};
  8	};
  9
 10	class Gradient
 11	{
 12	public:
 13		void Sort();
 14		Tga::Color Evaluate(float aTime) const;
 15
 16		std::vector<ColorKey>& AccessColorKeys();
 17		const std::vector<ColorKey>& AccessColorKeys() const;
 18
 19		uint32_t DisplayImGuiEdit();
 20
 21	private:
 22		uint32_t DisplayGradientEdit();
 23
 24		void AddGradientToDrawList(const ImVec2& aStart, const ImVec2& aEnd);
 25
 26		std::vector<ColorKey> myColorKeys = {{}};
 27
 28		int mySelectedColorKey = -1;
 29		float myTrackingX      = 0.f;
 30	};
 31}
 32
 33static constexpr ImColor HOVERED_COLOR     = IM_COL32(240, 240, 240, 255);
 34static constexpr ImColor DEFAULT_COLOR     = IM_COL32(170, 160, 170, 255);
 35static constexpr ImColor DELETE_COLOR      = IM_COL32(190, 40, 40, 255);
 36static constexpr ImColor DELETE_HOVER_COLOR = IM_COL32(190, 90, 90, 255);
 37
 38
 39constexpr const char* POPUP_NAME = "Curve Popup";
 40
 41static ImU32 TgaColorConvertToU32(const Tga::Color& in)
 42{
 43	ImU32 out = static_cast<ImU32>(IM_F32_TO_INT8_SAT(in.r)) << IM_COL32_R_SHIFT;
 44	out |= static_cast<ImU32>(IM_F32_TO_INT8_SAT(in.g)) << IM_COL32_G_SHIFT;
 45	out |= static_cast<ImU32>(IM_F32_TO_INT8_SAT(in.b)) << IM_COL32_B_SHIFT;
 46	out |= static_cast<ImU32>(IM_F32_TO_INT8_SAT(in.a)) << IM_COL32_A_SHIFT;
 47	return out;
 48}
 49void Goose::Gradient::Sort()
 50{
 51	for (int i = 0; i < static_cast<int>(myColorKeys.size()); i++)
 52	{
 53		int bestIndex    = i;
 54		float lowestTime = myColorKeys[i].time;
 55		for (int j = i + 1; j < static_cast<int>(myColorKeys.size()); ++j)
 56		{
 57			if (myColorKeys[j].time < lowestTime)
 58			{
 59				
 60				bestIndex  = j;
 61				lowestTime = myColorKeys[j].time;
 62			}
 63		}
 64
 65		std::swap(myColorKeys[i], myColorKeys[bestIndex]);
 66		if (bestIndex == mySelectedColorKey)
 67		{
 68			mySelectedColorKey = i;
 69		}
 70		else if (i == mySelectedColorKey)
 71		{
 72			mySelectedColorKey = bestIndex;
 73		}
 74	}
 75}
 76
 77Tga::Color Goose::Gradient::Evaluate(float aTime) const
 78{
 79	if (myColorKeys.size() == 1)
 80	{
 81		return myColorKeys.front().color;
 82	}
 83
 84	aTime = FMath::Clamp(aTime, 0.f, 1.f);
 85
 86	Tga::Color previousColor = myColorKeys.front().color;
 87	float previousTime       = 0.f;
 88	Tga::Color returnColor;
 89	
 90
 91	for (const ColorKey& colorKey : myColorKeys)
 92	{
 93		returnColor = colorKey.color;
 94		if (colorKey.time > aTime)
 95		{
 96			if (aTime == 0.f)
 97			{
 98				break;
 99			}
100
101			const float lerpTime = (aTime - previousTime) / (colorKey.time - previousTime);
102			returnColor          = FMath::Lerp(previousColor, colorKey.color, lerpTime);
103			break;
104		}
105		previousTime  = colorKey.time;
106		previousColor = colorKey.color;
107	}
108
109	return returnColor;
110}
111
112std::vector<Goose::ColorKey>& Goose::Gradient::AccessColorKeys()
113{
114	return myColorKeys;
115}
116
117const std::vector<Goose::ColorKey>& Goose::Gradient::AccessColorKeys() const
118{
119	return myColorKeys;
120}
121
122uint32_t Goose::Gradient::DisplayImGuiEdit()
123{
124	uint8_t info = 0;
125
126	
127
128	constexpr float spacingX = 10.f;
129	constexpr float graphHeight = 30.f;
130
131	if (globalIsUsingNodeEditor) // This does not display correctly in node editor and is disabled
132	{
133		ImGui::Text("_Variable_Only_");
134		return 0u;
135	}
136
137	const ImVec2 origin = ImGui::GetCursorScreenPos();
138	const float totalWidth = FMath::Min(ImGui::GetContentRegionAvail().x * 0.7f - spacingX * 2.f, 100.f);
139	const ImVec2 graphSize = ImVec2(totalWidth, graphHeight);
140
141	AddGradientToDrawList(origin, origin + graphSize);
142
143	ImGui::Dummy(graphSize);
144
145	const bool hovered = ImGui::IsItemHovered();
146	if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left))
147	{
148		ImGui::OpenPopup(POPUP_NAME);
149		ImGui::SetNextWindowPos(ImGui::GetCursorScreenPos());
150	}
151
152	if (ImGui::BeginPopup(POPUP_NAME, ImGuiWindowFlags_NoMove))
153	{
154		info |= DisplayGradientEdit();
155		ImGui::EndPopup();
156	}
157
158	return info;
159}
160
161uint32_t Goose::Gradient::DisplayGradientEdit()
162{
163	uint32_t info = 0;
164
165	const bool deleteMode = ImGui::GetIO().KeyShift && myColorKeys.size() > 1;
166
167	constexpr float spacingX = 10.f;
168	constexpr float spacingY = 10.f;
169	constexpr float keySize = 30.f;
170
171	
172	const ImVec2 origin        = ImGui::GetWindowPos() + ImVec2{0.f,spacingY };
173	constexpr float totalWidth = 300.f;
174	
175	const float startX         = origin.x + spacingX;
176	const float endX           = startX + totalWidth;
177	constexpr float gradientShowcaseHeight = 30.f;
178
179	ImDrawList* drawList = ImGui::GetWindowDrawList();
180	constexpr ImVec2 totalSize{ totalWidth,gradientShowcaseHeight + keySize + spacingY * 2.f };
181
182	bool hasBeenHovered = false;
183
184	const ImVec2 lineStartPoint = origin + ImVec2(0.f, keySize);
185	const ImVec2 lineEndPoint = origin + ImVec2(totalWidth, keySize);
186
187	drawList->AddLine(lineStartPoint, lineEndPoint, IM_COL32(200, 200, 200, 254), 2.f);
188
189	const ImColor noHoverColor = deleteMode ? DELETE_COLOR : DEFAULT_COLOR;
190	const ImColor hoverColor = deleteMode ? DELETE_HOVER_COLOR : HOVERED_COLOR;
191
192	const int initialSelected = mySelectedColorKey;
193
194	for (int i = 0; i < static_cast<int>(myColorKeys.size()); ++i)
195	{
196		ImGui::PushID(i);
197
198		const float xPosition = FMath::Lerp(startX, endX, myColorKeys[i].time);
199
200		ImVec2 drawPos = ImVec2(xPosition, keySize) + ImVec2{0.f,origin.y};
201
202		const Tga::Color initialColor = myColorKeys[i].color;
203
204		ImGui::SetCursorScreenPos(ImVec2(drawPos.x - 12.5f, drawPos.y - 30.f)); // 4
205		ImGui::ColorEdit4("##ColorEdit", myColorKeys[i].color.myValues, ImGuiColorEditFlags_NoInputs);
206
207		if (initialColor != myColorKeys[i].color)
208		{
209			info |= PropertyEdit::InfoFlag_ChangedValue;
210		}
211		if (ImGui::IsItemDeactivatedAfterEdit())
212		{
213			info |= PropertyEdit::InfoFlag_EditEnded;
214		}
215
216		bool hovered = false;
217		if (hasBeenHovered == false)
218		{
219			hovered = ImGui::IsMouseHoveringRect(drawPos - ImVec2(5.f, 5.f), drawPos + ImVec2(5.f, 5.f));
220			hasBeenHovered |= hovered;
221		}
222
223		drawList->AddCircleFilled(drawPos, 5.f, hovered ? hoverColor : noHoverColor);
224
225		if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && hovered)
226		{
227			if (deleteMode)
228			{
229				myColorKeys.erase(myColorKeys.begin() + i);
230				i--;
231				info |= PropertyEdit::InfoFlag_EditEnded;
232				info |= PropertyEdit::InfoFlag_ChangedValue;
233			}
234			else
235			{
236				mySelectedColorKey = static_cast<int>(i);
237				myTrackingX = xPosition + origin.x;
238			}
239
240		}
241
242		ImGui::PopID();
243	}
244
245	if (hasBeenHovered == false && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left))
246	{
247		if (ImGui::IsMouseHoveringRect(lineStartPoint - ImVec2(2.f, 2.f), lineEndPoint + ImVec2(2.f, 2.f)))
248		{
249			ColorKey newColorKey;
250			newColorKey.time = FMath::Clamp(FMath::InverseLerp(startX, endX, ImGui::GetMousePos().x), 0.f, 1.f);
251
252			myColorKeys.emplace_back(newColorKey);
253
254			info |= PropertyEdit::InfoFlag_EditEnded;
255			info |= PropertyEdit::InfoFlag_ChangedValue;
256
257			Sort();
258		}
259	}
260
261	
262	const ImVec2 gradientShowcaseRoot = lineStartPoint + ImVec2(0.f, 10.f);
263
264	AddGradientToDrawList(gradientShowcaseRoot, gradientShowcaseRoot + ImVec2{ totalWidth,gradientShowcaseHeight });
265
266	ImGui::SetCursorScreenPos(origin + ImVec2(0.f, gradientShowcaseHeight + 20.f));
267
268	if (ImGui::IsMouseReleased(ImGuiMouseButton_Left))
269	{
270		if (mySelectedColorKey != -1)
271		{
272			info |= PropertyEdit::InfoFlag_EditEnded;
273		}
274		mySelectedColorKey = -1;
275	}
276
277	myTrackingX += ImGui::GetIO().MouseDelta.x;
278
279	if (mySelectedColorKey != -1)
280	{
281		if (ImGui::GetIO().MouseDelta.x != 0.f)
282		{
283			info |= PropertyEdit::InfoFlag_ChangedValue;
284		}
285
286		myColorKeys[mySelectedColorKey].time = FMath::InverseLerp(startX, endX, myTrackingX - origin.x);
287		myColorKeys[mySelectedColorKey].time = FMath::Clamp(myColorKeys[mySelectedColorKey].time, 0.f, 1.f);
288
289		Sort();
290	}
291
292	if (initialSelected != mySelectedColorKey)
293	{
294		info |= PropertyEdit::InfoFlag_ChangedValue;
295	}
296
297	ImGui::SetCursorPos({ 0.f, 0.f });
298	ImGui::Dummy(totalSize);
299
300	return info;
301}
302
303void Goose::Gradient::AddGradientToDrawList(const ImVec2& aStart, const ImVec2& aEnd)
304{
305	const float gradientShowcaseHeight = aEnd.y - aStart.y;
306	float lastX = aStart.x;
307	ImColor lastColor = TgaColorConvertToU32(myColorKeys.front().color);
308
309	ImDrawList* drawList = ImGui::GetWindowDrawList();
310	for (size_t i = 0; i < myColorKeys.size(); ++i)
311	{
312		ImColor currentColor = TgaColorConvertToU32(myColorKeys[i].color);
313		const float xPosition = FMath::Lerp(aStart.x, aEnd.x, myColorKeys[i].time);
314
315		const ImVec2 subSize = ImVec2(xPosition - lastX, gradientShowcaseHeight);
316		const ImVec2 subCorner = ImVec2{ 0.f,aStart.y } + ImVec2(lastX, 0.f);
317
318		drawList->AddRectFilledMultiColor(subCorner, subCorner + subSize, lastColor, currentColor, currentColor, lastColor);
319
320		lastColor = currentColor;
321		lastX = xPosition;
322	}
323	{
324		ImColor currentColor = TgaColorConvertToU32(myColorKeys.back().color);
325		const float xPosition = aEnd.x;
326
327		const ImVec2 subSize = ImVec2(xPosition - lastX, gradientShowcaseHeight);
328		const ImVec2 subCorner = ImVec2{ 0.f,aStart.y } + ImVec2(lastX, 0.f);
329
330		drawList->AddRectFilledMultiColor(subCorner, subCorner + subSize, lastColor, currentColor, currentColor, lastColor);
331	}
332}

Rendering

In the rendering tab, you can assign a material to render with and what type of particle to render. The default type is a Billboarded sprite. Another sprite option is Velocity Sprite, which will align the sprite’s y-axis to the direction it travels in and then rotate it along the y-axis towards the camera.

There are also different UV options. You can choose to scale the UV and render with random regions.

white kitten

The last particle type is Mesh. Having this option on will also enable rotation options around the x and y axis.

white kitten

Multiple Emitters

A particle system supports multiple emitters, allowing for multiple particle types in one system.

Additionally, you can add events, that execute on particle birth or death, that can trigger a specific emitter.

white kitten

Gravity & Noise

This was the last feature I added to the particle editor. Gravity will apply a force on the particle over time, and you can choose if the gravity direction is down or towards a certain point.

Particle gravity gif

Noise will pseudo-randomly offset the particle depending on its position. Strength controls how big the offset is, frequency controls how big the variation is and update time dictates how often the noise is recalculated

Particle noise gif

Future Improvements

The particle system has a lot of features, but I have also noticed that it has performance drops when handling 50k+ particles in a scene with other things to render. There are a few ways I could improve performance. One way is to separate the particle data into multiple smaller structs. Currently my particle struct is very big:

 1struct Particle
 2{
 3	Tga::Color startColor;
 4
 5	float startLifeTime{};
 6	float lifeTimer{};
 7
 8	float startSpeed{};
 9	float currentSpeed{};
10
11	float startSize{};
12	float currentSize{};
13
14	float rotation{};
15	float startSpin{};
16	float currentSpin{};
17
18	float rotationX{};
19	float startSpinX{};
20	float currentSpinX{};
21
22	float rotationY{};
23	float startSpinY{};
24	float currentSpinY{};
25
26	Tga::Vector3f direction;
27	Tga::Vector3f position;
28	Tga::Vector3f gravityVelocity;
29
30	Tga::Vector3f targetNoiseVelocity;
31	Tga::Vector3f noiseVelocity;
32	float noiseTime{};
33
34	Tga::Vector2f uvPosition;
35};

When I loop through all the particles just to check lifetime, this will not be cache friendly since variables that are nearby will be loaded in memory but not used.

Another improved to performance I could do is updating particles using a compute shader, which would be much faster than the current CPU approach. This is something I definitely want to do in the future, however I have prioritised adding features instead since the particle system isn’t one of our biggest bottle neck to performance in our games.

I am part of The Game Assembly’s internship program. As per the agreement between the Games Industry and The Game Assembly, neither student nor company may be in contact with one another regarding internships before April 15. Any internship offers can be made on April 27th, at the earliest.