Search Unity

[FREE] More Effective Coroutines

Discussion in 'Assets and Asset Store' started by Trinary, Feb 23, 2016.

  1. imaginationrabbit

    imaginationrabbit

    Joined:
    Sep 23, 2013
    Posts:
    349
    Thank you for the reply and for the info- I'm a beginner coder so some usage examples for the threading asset would be great in the future- thank you.
     
  2. imaginationrabbit

    imaginationrabbit

    Joined:
    Sep 23, 2013
    Posts:
    349
    I'm successfully using MEC Pro + Threading in two of my scripts- In another I'm doing exactly the same thing as the others but getting this error- any hints on what I should be looking out for?

    I'm running the coroutine with this
    Code (CSharp):
    1. Timing.RunCoroutine(_CheckDeathDistance().CancelWith(gameObject));
    In the coroutine I switch to a bg thread- run a function and switch back like in my other working scripts but this one throws up the errors.

    The only difference between this object/script and the others I have working well is that it is spawned from a pool- the others are already in the scene- I made sure the Coroutines are executed after the object is spawned and active in the scene though.

    I think I found the issue- if a game object has a script using mec threading and it is deactivated in the scene on start as are items used in a pool- if that game object is then activated the error appears- it happens if the coroutine runs in start or if it is run later-

    I tried using the asset Easy Threading in place of the Mec threading in the scripts and deactivated/activated objects work without errors inside of the Mec pro coroutines-
     
    Last edited: Apr 26, 2017
  3. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    Oh man.. yeah, CancelWith won't work from another thread. I'm going to have to make a fix for that in the next update. For now it looks like you can't use CancelWith with a threaded coroutine. Sorry :(
     
  4. imaginationrabbit

    imaginationrabbit

    Joined:
    Sep 23, 2013
    Posts:
    349
    No prob- I got it working with another threading asset while still using Mec Pro- I'll switch the scripts over when there's a fix as I'd like to use your integrated solution for threading. Thanks again.
     
  5. JRRReynolds

    JRRReynolds

    Joined:
    Oct 29, 2014
    Posts:
    192
    Does Movement/Timing include MEC Pro -- from the description it seems like it does but they are both the same price so I wasn't sure? I wanted to pick up MEC pro but if Movement/Timing includes it I may as well get that and also pick up the benefits of Movement/Timing right?
     
  6. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    @Zrexa Thanks for asking. I'll be updating the description (and other things) this weekend. Basically, it contains a 6 month old version of MEC Pro. MEC was originally a piece of MoT, but I've found that MEC is more useful to more people than MoT and so MEC has gotten a lot more updates and grown a lot faster.

    The old version of MEC Pro that is included is going to be replaced with a recent version of MEC Free soon, so if MEC Pro is the product you are interested in then I suggest you get that one. The old version that's in the MoT package is not compatible with the MEC threading module and it has fewer features.
     
  7. Nandorand

    Nandorand

    Joined:
    Sep 18, 2013
    Posts:
    7
    @Trinary Is it possible to manage several threads (or threaded coroutines) with your MEC multithreaded asset? It would be nice to have a little examples and some docs :)
     
  8. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    I agree @Niarus . I'll be putting that up as soon as I finish fixing the current list of outstanding issues.

    To answer your question real quick; each coroutine is sent to a thread pool (which has as many threads as there are cores on the current system). You can choose your thread priority if you like. The thread pool executes anything sent to it in the order received.

    Very long running coroutines can be sent to a non-pooled dedicated thread.

    Do you need to manage them with any more precision than that?
     
  9. Nandorand

    Nandorand

    Joined:
    Sep 18, 2013
    Posts:
    7
    @Trinary Thank you for a quick response.
    Take your time.

    I've just checked out the code in Threading.cs and got the idea, at least superficially :) I guess it's the best approach and can be altered if anything.
     
  10. alsharefeeee

    alsharefeeee

    Joined:
    Jul 6, 2013
    Posts:
    80
    Question please, how do you "WaitUntilDone" for a coroutine to finish from another script?

    And thank you so much for this great asset.
     
  11. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
  12. Quatum1000

    Quatum1000

    Joined:
    Oct 5, 2014
    Posts:
    889
    I download the free version for testing purposes: but got an error. Version 02may2017.
    Untitled-1.jpg

    Assets/C#/SceneManagerTest.cs(5,7): error CS0246: The type or namespace name `MovementEffects' could not be found. Are you missing an assembly reference?

    Found a solution: They only way was to temporarily rename the original script folder.

    But anyway is there any reason for using /PlugIns for your asset?
     
    Last edited: May 4, 2017
  13. alsharefeeee

    alsharefeeee

    Joined:
    Jul 6, 2013
    Posts:
    80
    Thanks,

    I used the below and its working with me :)

    Code (CSharp):
    1.  IEnumerator<float> _WaitBeforeResume()
    2.     {
    3.         //start the numbering sequence
    4.         Timing.RunCoroutine(myNumbering.PlayNumbering());
    5.         var Handle = Timing.RunCoroutine(myNumbering.PlayNumbering());
    6.         //wait for it to finish
    7.         yield return Timing.WaitUntilDone(Handle);
    8.         //Resume the game
    9.         PauseScript.ResumeGame();
    10.     }
     
  14. Quatum1000

    Quatum1000

    Joined:
    Oct 5, 2014
    Posts:
    889
    I use AsyncOperation

    Code (CSharp):
    1.  
    2. public IEnumerator<float> _LoadScenePart(string ScenePart) {
    3.  
    4. // https://docs.unity3d.com/2017.1/Documentation/ScriptReference/AsyncOperation-allowSceneActivation.html
    5.         AsyncOperation async = SceneManager.LoadSceneAsync(ScenePart, LoadSceneMode.Additive);
    6.  
    7.         async.allowSceneActivation = true;
    8.         while (!async.isDone)
    9.            yield return async; //<*****************
    10.  
    11. // error CS0029: Cannot implicitly convert type `UnityEngine.AsyncOperation' to `float'
    12.  
    13. }
    14.  
    what do I have to use in case of async to make the AsyncOperation wait for finish?
    yield return Timing.WaitForOneFrame; ?
     
    Last edited: May 4, 2017
  15. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    @Quatum1000 You can use yield return Timing.WaitUntilDone(async).

    The while loop you have there is redundant, when you yield return async it waits for async.isDone before returning, so your while loop can only ever be executed 0 or 1 times.
     
  16. Quatum1000

    Quatum1000

    Joined:
    Oct 5, 2014
    Posts:
    889
    That means to me. yield return Timing.WaitUntilDone(async); without the while loop does the job.

    If I want to display a status bar inside of the while loop until !async.isDone. then Timing.WaitForOneFrame; should be used.
    Code (CSharp):
    1.  
    2.        async.allowSceneActivation = true;
    3.        while (async.progress < 0.9f) {
    4.               StressBar.Text =  "Load from async.progress < 0.9 : " + async.progress.String();
    5.         }
    6.  
    7.         float perc = 0.5f;
    8.         while (!async.isDone) {
    9.             yield return Timing.WaitForOneFrame; //*****************
    10.             perc = Mathf.Lerp(perc, 1f, 0.05f);
    11.             StressBar.Text =  "Load from async.progress >= 0.9 : " + perc.String();
    12.         }
     
    Last edited: May 4, 2017
  17. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    @Quatum1000 That looks good, but remember to put a "yield return Timing.WaitForOneFrame;" inside the top while loop as well or you'll freeze the app.
     
  18. Quatum1000

    Quatum1000

    Joined:
    Oct 5, 2014
    Posts:
    889
    Into this loop?

    1. async.allowSceneActivation = true;
    2. while (async.progress < 0.9f) {
    3. -》 yield return Timing.WaitForOneFrame;
    4. StressBar.Text = "Load from async.progress < 0.9 : " + async.progress.String();
    5. }
     
    Last edited: May 15, 2017
  19. PhoenixRising1

    PhoenixRising1

    Joined:
    Sep 12, 2015
    Posts:
    488
    Hey.
    I haven't used Unity for a long time and now when I got back to it I've ran into some problem I don't remember having before - the coroutines run twice the frame they're started if there's a while loop present:

    Code (CSharp):
    1. private IEnumerator<float> Test()
    2.     {
    3.         while (true)
    4.         {
    5.             print("hi");
    6.             yield return 0f;
    7.         }
    8.     }
    A 'yield return 0f;' doesn't delay the coroutine to run the next frame:

    Code (CSharp):
    1. private IEnumerator<float> Test()
    2. {
    3.         print("hi");
    4.         yield return 0f;
    5.         print("hi");
    6. }
     
    Last edited: May 15, 2017
  20. TiberiuMunteanu

    TiberiuMunteanu

    Joined:
    May 9, 2015
    Posts:
    18
    I have a pool of disabled bullet stamp objects. When I need one, I get it out of the pool, I activate it and call its Init function which calls a Timing.RunCoroutine(ReleaseCoroutine(), instanceId, RELEASE_STAMP_TAG). The ReleaseCoroutine waits for 2 seconds and calls Release() on the object, which deactivates the object and places it back into the pool.
    However, if I reset the level, I call Timing.KillCoroutines(instanceId, RELEASE_STAMP_TAG), and call Release() myself.
    This is how I use MEC in my game, and it seems to allocate quite a lot every time I run a coroutine because it calls AddTag() and AddLayer() and every time I kill a coroutine because it calls RemoveGraffiti.
    Am I using MEC wrong?
    How should I get the described functionality without the allocs?
     
  21. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    @ephemeral-life This is due to the script execution order. If you start your coroutine from within a script that happens to be run after the Timing module runs then there won't be an issue. If your script runs before then the first frame will run twice.

    I've been trying to restructure the code so that it doesn't matter when the script happens to execute, but that isn't quite ready yet. You can fix it in your project by going to project settings/script execution order and moving the Timing class to before everything else.

    @TiberiuMunteanu Using tags or layers does trigger a memory alloc whenever the data structure that holds tags and layers has to be expanded, however it shouldn't be every frame and if you end one coroutine with a layer and add another that shouldn't result in an alloc. Can you confirm that you are seeing the alloc every time?

    Also, if you set the profiler debug amount to Seperate Tags that will create a lot of allocation in the profiler, but that allocation will not happen when the profiler window has not been visible since the last time you restarted unity.
     
    Last edited: May 16, 2017
    PhoenixRising1 likes this.
  22. PhoenixRising1

    PhoenixRising1

    Joined:
    Sep 12, 2015
    Posts:
    488
    Thanks for the fast reply, and I'm glad that it's easily solvable. :)
     
    Trinary likes this.
  23. TiberiuMunteanu

    TiberiuMunteanu

    Joined:
    May 9, 2015
    Posts:
    18
    The alloc does not happen every frame, it happens when I Run a coroutine and sometimes even when i Release a coroutine. The thing is that I do not start another coroutine after I kill a coroutine..and the TIming code clearly does a RemoveGraffiti which deletes the tag...only to be added later with a new alloc.

    Think about it this way: I have 2 buttons.. "Start" and "Kill". When I press Start it triggers a Timing.RunCoroutine(DoStuff(), instanceId, TAG); When I press Kill, it triggers a Timing.KillCoroutines(instanceId, TAG);
    If I do: Start, Kill, Start, Kill, it will result in 0.9K of alloc on each Start due to the AddTag() and AddLayer() invokes, and sometimes a few bytes on Kill which invokes RemoveGrafitti().

    ..so this made me think that maybe I am using coroutines for the wrong thing.. maybe what I need is just some invoke delayed functionality with cancel token or smth.
     
  24. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    I'll look to make sure there isn't an unintentional alloc as soon as I can, but here is what is supposed to be happining: Tags and layers are stored in HashSets, and hashsets increase their size whenever they don't have enough buckets left to hold the data you're putting in them. When they do that it will show up as a GC alloc. Whenever you remove data that should make room for more.

    Anyway, tags and layers will always have some GC alloc. Using CoroutineHandle is finally stable though. You can make a private variable that holds the CoroutineHandle and set it when RunCoroutine returns. You can then do KillCoroutines(handle) if you want to kill it. Make sure you are on the latest version, in a couple of old versions CoroutineHandles weren't working correctly. Doing it that way will avoid the GC alloc.
     
  25. N3V-Developer

    N3V-Developer

    Joined:
    Jun 7, 2014
    Posts:
    20
    Would you please post a small code example for the solution?

    Further I'd like to use the co-routine inside another thread. I read something about in the documentation and also MEC Threaded and what does mean Realtime Update.
     
  26. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    Sure:
    Code (CSharp):
    1. using System.Collections.Generic;
    2. using MovementEffects;
    3. using UnityEngine;
    4.  
    5. public class SanityTest : MonoBehaviour
    6. {
    7.     private CoroutineHandle handle;
    8.  
    9.     public float max;
    10.     public float min;
    11.     public float timeToReach;
    12.  
    13.     void OnEnable()
    14.     {
    15.         handle = Timing.RunCoroutine(_MoveBackAndForth());
    16.     }
    17.  
    18.     void OnDisable()
    19.     {
    20.         Timing.KillCoroutines(handle);
    21.     }
    22.  
    23.     private IEnumerator<float> _MoveBackAndForth()
    24.     {
    25.         while(gameObject != null)
    26.         {
    27.             Vector3 start = transform.position;
    28.             float startTime = Timing.LocalTime;
    29.  
    30.             while(transform.position.x < max)
    31.             {
    32.                 transform.position = Vector3.Lerp(start, new Vector3(max, transform.position.y, transform.position.z),
    33.                     (Timing.LocalTime - startTime) / timeToReach);
    34.                 yield return Timing.WaitForOneFrame;
    35.             }
    36.  
    37.             start = transform.position;
    38.             startTime = Timing.LocalTime;
    39.             while(transform.position.x > min)
    40.             {
    41.                 transform.position = Vector3.Lerp(start, new Vector3(min, transform.position.y, transform.position.z),
    42.                     (Timing.LocalTime - startTime) / timeToReach);
    43.                 yield return Timing.WaitForOneFrame;
    44.             }
    45.         }
    46.     }
    47. }
    48.  
    MEC Threaded is an add-on module for MEC. I haven't written the documentation for it yet, but that's where the docs will go once they're ready.

    RealtimeUpdate is the same as update, but if you set the timescale in unity to something other than 1 then Timing.LocalTime and Timing.DeltaTime will ignore that, whereas in the Update segment time will progress slower or faster.
     
    Last edited: May 19, 2017
    Quatum1000, Alverik and N3V-Developer like this.
  27. N3V-Developer

    N3V-Developer

    Joined:
    Jun 7, 2014
    Posts:
    20
    Thank you. I'd like to buy Threaded if some example & doc included.
    Does Threaded works out of the box with MEC pro too?

    MEC Threaded can be used as a common solution for jitter nearly-free streaming. I read about

    You cannot read or write the values of anything in your scene.
    This includes gameObjects, transforms, buttons, gui text components, etc.
    You cannot query the Random class for a random number.


    Not sure if asynchronous scene load and save is thread safe in unity.
     
  28. Quatum1000

    Quatum1000

    Joined:
    Oct 5, 2014
    Posts:
    889
    yeah, wana use this also for load and unload :)
     
  29. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    Yes, MEC Threaded works out of the box with MEC Pro. I submitted an update that adds an example scene and example common usage scripts last weekend, but Unity didn't get a chance to approve the update this week. It'll be in the next update which should happen soon and might happen later today.. but I can't make any firm promises on that.

    I have been getting a lot of questions about the LoadLevelAsync. I'm pretty sure that function already uses threading under the hood, and I don't think that starting it from its own thread will fix the problem where you can only activate the scenes in the same order you loaded them.

    Threading is difficult in the best times, I'm doing what I can to make it useable in Unity, but it is still difficult to use it correctly even for simple cases.
     
    Quatum1000 likes this.
  30. Quatum1000

    Quatum1000

    Joined:
    Oct 5, 2014
    Posts:
    889
    Would be the best to test loading/unloading with large un-cached 8k x 8k textures and about ~64MB mesh/colliders in the main and other thread.
     
  31. Shadeless

    Shadeless

    Joined:
    Jul 22, 2013
    Posts:
    136
    @Trinary Hey so I just noticed something with MEC's WaitUntilDone which is inconsistent with how Unity Coroutines seem to work.

    So I had a setup with Unity coroutines where I called 2 coroutines in a row with yield return StartCoroutine(Coroutine);
    These coroutines set a flag to true at the start and false at the end. So when this runs, that flag is true until the second coroutine ends.

    In order to change to MEC I had to change that to yield return WaitUntilDone(MEC-Coroutine);
    However, when the first coroutine ends, it ends the frame and the flag is false for a frame, until it gets set to true the next frame by the second coroutine.

    So I just noticed that difference and wanted to ask if you knew about this, and if there is a workaround?

    Cheers
     
  32. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    @Quatum1000 Can you explain the problem you are trying to solve in more detail? I'm not entirely sure that we are talking about the same issue.

    @Shadeless That's interesting, I'm not sure what implications it would have if paused coroutines were run instantly rather than next frame. I'll look into it.

    I think the Append extension method would behave as you want: "yield return WaitUntilDone(_Coroutine1().Append(_Coroutine2()));"
     
    Quatum1000 likes this.
  33. Quatum1000

    Quatum1000

    Joined:
    Oct 5, 2014
    Posts:
    889
    I want so slow down the async scene un/loading as much as possible. If i start async loading in the main thread with about 8k x 8k and 64gb meshes it starts to yerk and jitter if
    AsyncOperation.isDone is 》0.9.
    My idea was to use another thread for async loading, allowSceneActivation and coroutine if .isDone is 》 0.9 between frames too.

    https://docs.unity3d.com/ScriptReference/AsyncOperation-allowSceneActivation.html
    When allowSceneActivation is set to false then progress is stopped at 0.9. The isDone is then maintained at false. When allowSceneActivation is set to true isDone can complete.

    There is no way to solve this without try and error.
     
  34. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    Unfortunately I don't think Unity has two way communication built into their AsyncOperation object.

    I think the only way to slow it down would be to break the actual scene up into smaller parts. You could do that by creating, say, 10 scenes with each scene having a piece of the final scene.. or you could do that by breaking your large assets into asset bundles, perhaps putting them into the StreamingAssets folder, and loading those assets one by one into a nearly empty scene.
     
  35. Quatum1000

    Quatum1000

    Joined:
    Oct 5, 2014
    Posts:
    889
    Code (CSharp):
    1.         PriorityTest priorityTest = new PriorityTest();
    2.         Thread thread1 = new Thread(priorityTest.ThreadMethod);
    3.         thread1.Priority = System.Threading.ThreadPriority.BelowNormal;
    4.         //  from main as co-routine thread1.Sleep(n);
    5.  
     
  36. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    I'm not sure where you are going with that, Quatum. That is a way to start a thread that works in Unity. MEC Threaded allows you to specify the priority and pools the tasks automatically, and handles the difficult task of seamlessly switching back to the main thread.
     
  37. Quatum1000

    Quatum1000

    Joined:
    Oct 5, 2014
    Posts:
    889
    Great, but without explanation and examples, I shifted this stuff more than 2 weeks sidewards. I will buy now and have a look into, perhaps I found any solution for my problem.
     
  38. Quatum1000

    Quatum1000

    Joined:
    Oct 5, 2014
    Posts:
    889
    Tested "Test Scene 1", on my fast system the capsules jerk and jitter a lot. Is this normal?
    I got 1.2Kb GC on ProcessingCoRoutine > SwitchToExternalThread()
     
  39. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    I've been working on both of those issues. I expect to be able to solve them, but I'm spending all of my available time trying to find the specific cause of that alloc and the jitter, which is why I haven't had time to put any documentation on the website yet.

    That jitter in the example scene may be largely caused by synchronization. It doesn't seem to happen on all systems.
     
    Last edited: May 25, 2017
    Quatum1000 likes this.
  40. Quatum1000

    Quatum1000

    Joined:
    Oct 5, 2014
    Posts:
    889
    Ah okay, thank you!
     
  41. Shadeless

    Shadeless

    Joined:
    Jul 22, 2013
    Posts:
    136
    @Trinary Hey, are there any options If we don't want to have garbage every time we start a coroutine? Like can we store a reference to the IEnumerator<float> and reuse it? Can we reset it's state? Is there a pooling solution we can make or something? I've been noticing a lot of garbage in my profiler, especially when using WaitUntilDone..

    Cheers
     
  42. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    It's not easy to do, but you can reuse coroutines. Once the function ends via either exception, falling through to the end, or a yield break it can't be accessed again, so you have to avoid that.

    So step one is to write the coroutine function in such a way that it never ends.
    Step two is to create a variable of type IEnumerator<float> and store the reference to your coroutine instance.
    Step three is to use KillCoroutines on the handle (which you also need to store) to the coroutine whenever you want to end the coroutine. (Note: If you use KillCoroutines from within the coroutine function that function will continue executing until the next yield return statement.)
    Step four is to pass your stored reference into RunCoroutine when you want to use it again. You'll need to catch the handle and update it because it will be different.
     
    Shadeless and Alverik like this.
  43. Shadeless

    Shadeless

    Joined:
    Jul 22, 2013
    Posts:
    136
    @Trinary So I'd have to carefully manage the coroutines as to not Start an already Running coroutine.
    If I end my coroutines with KillCoroutine() and then yield return float.NaN; will that properly end a coroutine and be ready to reuse?
     
  44. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    Both KillCoroutine(handle) and yield return float.NaN; will take it out of the execution list so you can add it later. You don't need to do both. You can always use handle.IsRunning to check if it's still running.
     
    Alverik likes this.
  45. Shadeless

    Shadeless

    Joined:
    Jul 22, 2013
    Posts:
    136
    @Trinary so wait all I have to do is return float.NaN at any point the coroutine needs to end (like instead of yield break; and at the very end) and it will be reusable?
    I've been reading about IEnumerator so it's not clear to me how exactly you reset the state of the IEnumerator when you Run it again? How does it start from the start again since MoveNext has advanced it to a further state.

    Cheers
     
  46. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    It doesn't start over. It starts from the last yield return that you left it at. So you would normally put the yield return float.nan at the bottom of a while(true) loop that brings execution back to the top.
     
    Alverik likes this.
  47. Shadeless

    Shadeless

    Joined:
    Jul 22, 2013
    Posts:
    136
    Oh, so you meant you have to design the coroutines specifically like that for them to be reusable. But if we interrupt a coroutine by using KillCoroutine, Then it will stop at the last yield return, so if we Run it, it will continue from there and not from the start... So we'd probably want to implement the interruption inside the coroutine, somehow..
     
    Trinary likes this.
  48. PhoenixRising1

    PhoenixRising1

    Joined:
    Sep 12, 2015
    Posts:
    488
    What's preferable in regards to performance (for multiple instances of a script):

    1. Use a CoroutineHandle to start and stop coroutines
    2. Use a tag like so:
    string startSomething = startSomething + name;
    Timing.RunCoroutine(_StartSomething(), startSomething);
     
    Last edited: Jun 17, 2017
  49. Trinary

    Trinary

    Joined:
    Jul 26, 2013
    Posts:
    395
    Your example for tags will create a memory alloc before you even start the coroutine.

    CoroutineHandles are created no matter if you use them or not, but the dictionaries that store the tags and layers don't allocate memory unless you use them. Tags and layers are mostly for handling groups of coroutines automatically.

    If you're already dealing with your coroutines on a one-by-one basis you should use the handle. It will always be more efficient in terms of performance to use the CoroutineHandle, but tags and layers can be more convenient for grouping.
     
    Last edited: Jun 17, 2017
    PhoenixRising1 likes this.
  50. PhoenixRising1

    PhoenixRising1

    Joined:
    Sep 12, 2015
    Posts:
    488
    Really appreciate the fast reply, thanks a lot. It's what I assumed then. Have a great rest of the weekend.
     
    Trinary likes this.