{"id":490,"date":"2024-06-25T16:37:00","date_gmt":"2024-06-25T16:37:00","guid":{"rendered":"https:\/\/armintajik.com\/?p=490"},"modified":"2025-09-02T20:57:17","modified_gmt":"2025-09-02T20:57:17","slug":"interactive-fluid","status":"publish","type":"post","link":"https:\/\/armintajik.com\/index.php\/2024\/06\/25\/interactive-fluid\/","title":{"rendered":"Interactive Ferrofluid"},"content":{"rendered":"\n<figure class=\"wp-block-image size-medium is-resized\"><img loading=\"lazy\" decoding=\"async\" width=\"300\" height=\"241\" src=\"https:\/\/armintajik.com\/wp-content\/uploads\/2024\/06\/Screenshot-2024-10-07-at-12.04.11\u202fAM-300x241.png\" alt=\"\" class=\"wp-image-564\" style=\"width:423px;height:auto\" srcset=\"https:\/\/armintajik.com\/wp-content\/uploads\/2024\/06\/Screenshot-2024-10-07-at-12.04.11\u202fAM-300x241.png 300w, https:\/\/armintajik.com\/wp-content\/uploads\/2024\/06\/Screenshot-2024-10-07-at-12.04.11\u202fAM.png 742w\" sizes=\"auto, (max-width: 300px) 100vw, 300px\" \/><\/figure>\n\n\n\n<p>This is an attempt to prototype a mobile game idea that uses magnetic forces and fluid simulation inspired by <a href=\"https:\/\/en.wikipedia.org\/wiki\/Ferrofluid\">ferrofluids<\/a>.<\/p>\n\n\n\n<figure class=\"wp-block-embed is-type-video is-provider-vimeo wp-block-embed-vimeo\"><div class=\"wp-block-embed__wrapper\">\n<iframe loading=\"lazy\" title=\"Ferrofluid - Using noise to lower sampling count\" src=\"https:\/\/player.vimeo.com\/video\/1016954271?dnt=1&amp;app_id=122963\" width=\"422\" height=\"750\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\"><\/iframe>\n<\/div><\/figure>\n\n\n\n<p>I chose Unreal Engine 5 for the task. After setting up the project repo using Perforce and preparing the build pipeline for iOS, I was ready to make the prototype.<\/p>\n\n\n\n<p>I started with the magnetic field, the idea here is when you click on an astroid, it becomes magnetic and attracts the particles. It&#8217;s not the best gameplay mechanic I know, but it was a good enough start to test the magnetic forces.<\/p>\n\n\n\n<figure class=\"wp-block-embed is-type-video is-provider-vimeo wp-block-embed-vimeo\"><div class=\"wp-block-embed__wrapper\">\n<iframe loading=\"lazy\" title=\"Ferrofluid - Prototyping magnetic forces\" src=\"https:\/\/player.vimeo.com\/video\/1016930010?dnt=1&amp;app_id=122963\" width=\"422\" height=\"750\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\"><\/iframe>\n<\/div><figcaption class=\"wp-element-caption\">Prototyping ferromagnetic forces<\/figcaption><\/figure>\n\n\n\n<p>Upon my initial investigation, I realized that although UE5 has a <a href=\"https:\/\/dev.epicgames.com\/documentation\/en-us\/unreal-engine\/niagara-fluids-in-unreal-engine\">powerful fluid simulation system<\/a>, it <a href=\"https:\/\/forums.unrealengine.com\/t\/what-is-up-with-niagara-fluids-not-working-on-a-mac\/884774\">doesn&#8217;t work in macOS or iOS<\/a>. Honestly, I didn&#8217;t expect it either, as I&#8217;m already surprised that UE5 runs smoothly on M-series MacBooks!<\/p>\n\n\n\n<p>I stumbled upon <a href=\"https:\/\/forums.unrealengine.com\/t\/dungeon-slime\/141179\" data-type=\"link\" data-id=\"https:\/\/forums.unrealengine.com\/t\/dungeon-slime\/141179\">this brilliant post<\/a> while searching for another solution. The idea is to keep the number of particles limited (64, for example) and use a rendering technique called ray marching to smooth these particles together into a fluid-like substance. These smoothed spheres are also called metaballs.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full is-resized\"><a href=\"https:\/\/commons.wikimedia.org\/wiki\/File:Metaball_contact_sheet.png\"><img loading=\"lazy\" decoding=\"async\" width=\"700\" height=\"200\" src=\"https:\/\/armintajik.com\/wp-content\/uploads\/2024\/06\/image.png\" alt=\"\" class=\"wp-image-558\" style=\"width:880px;height:auto\" srcset=\"https:\/\/armintajik.com\/wp-content\/uploads\/2024\/06\/image.png 700w, https:\/\/armintajik.com\/wp-content\/uploads\/2024\/06\/image-300x86.png 300w\" sizes=\"auto, (max-width: 700px) 100vw, 700px\" \/><\/a><figcaption class=\"wp-element-caption\">This is how different levels of smoothness affects metaballs<\/figcaption><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>As far as I know, Unreal Engine doesn&#8217;t support metaballs or even ray marching by default. In the post mentioned above, the author uses a plugin, but it is outdated now. I tried finding other add-ons but didn&#8217;t find anything good. Therefore, I decided to create it from scratch. This way, I had more control over the results and the opportunity to learn ray marching along the way.<\/p>\n\n\n\n<p><\/p>\n\n\n\n<p>However, this decision meant that I had to write some HLSL in UE5. I&#8217;ve had prior experience writing HLSL code in Unity, and while Unity supports HLSL really well, it&#8217;s not the same in Unreal Engine; You can write custom HLSL code, using a <a href=\"https:\/\/dev.epicgames.com\/documentation\/en-us\/unreal-engine\/custom-material-expressions-in-unreal-engine?application_version=5.2\">Custom<\/a> node in material editor, but it&#8217;s really user-unfreindly (or unuser-friendly?) and you can&#8217;t even reference a file and have to use a plain text field to write your code. I was searching for resources on that and found this epic (pun intended) <a href=\"https:\/\/youtu.be\/HaUAfgrZjlU?si=W-ulH7AULqou9Fja\">live training session<\/a>.<br><\/p>\n\n\n\n<p>This was a great beginning as it helped me create a metaball shader. However, I wanted to share the location of the spheres at runtime without having to create a hundred nodes (to be exact, 128 for 64 spheres) in material editor, then reference them in C++. The real nightmare was changing the number of spheres. Imaging redoing all of that again.<\/p>\n\n\n\n<p>That&#8217;s why I used render targets to feed the spheres&#8217; location to the custom node, which loops over the pixels and sets the spheres&#8217; location and radius based on their RGBA value.<\/p>\n\n\n\n<figure class=\"wp-block-embed is-type-video is-provider-vimeo wp-block-embed-vimeo wp-embed-aspect-16-9 wp-has-aspect-ratio\"><div class=\"wp-block-embed__wrapper\">\n<iframe loading=\"lazy\" title=\"Ferrofluid - Transferring data using Render Targets\" src=\"https:\/\/player.vimeo.com\/video\/1016939836?dnt=1&amp;app_id=122963\" width=\"500\" height=\"281\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\"><\/iframe>\n<\/div><figcaption class=\"wp-element-caption\">Transferring run-time data to the custom shader using render targets.<\/figcaption><\/figure>\n\n\n\n<p>This is the first try, and the code is not optimized. The final material is something like this, fairly more optimized and cleaner &#8211; dear Epic, please improve the code editor in material graphs!)<\/p>\n\n\n\n<p>At this point, All the particles were <code>Actors<\/code>, and I used the <code>UStaticMeshComponent<\/code> class for particles and their <code>AddImpulse()<\/code> method to move them around. This approach was good enough to test things out but not efficient, especially considering that I didn&#8217;t need most of the <code>UStaticMeshComponent<\/code> features. I thought of using <code>USphereComponent<\/code>, but even then, I was using CPU threads to process something suited for the GPU.<\/p>\n\n\n\n<p>That&#8217;s why I considered the Niagara System (not Niagara Fluid System). It made sense because I wanted to utilize the GPU while incorporating physics and collisions, and Niagara System provided all of these features. I just had to connect user input to the particle system and the particle system to the ray marching shader.<\/p>\n\n\n\n<figure class=\"wp-block-embed is-type-video is-provider-vimeo wp-block-embed-vimeo\"><div class=\"wp-block-embed__wrapper\">\n<iframe loading=\"lazy\" title=\"Ferrofluid - Niagara particle system\" src=\"https:\/\/player.vimeo.com\/video\/1016954288?dnt=1&amp;app_id=122963\" width=\"422\" height=\"750\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\"><\/iframe>\n<\/div><figcaption class=\"wp-element-caption\">Using Niagara System<\/figcaption><\/figure>\n\n\n\n<p>At first, I wanted to calculate the attraction force for each particle, but it was too slow. While I had a lot of &#8220;fun&#8221; trying to create a Niagara Data Interface, I did not use it and just sent one vector as the mean magnetic center to the system.<\/p>\n\n\n\n<p>Here, I packaged the prototype for iOS and tried it on my phone. It was really slow! And I wasn&#8217;t surprised because ray marching is an expensive technique. I tried lowering the sample count to reduce the lag, but it caused weird visual artifacts. To solve that, I added some noise to the sampling distance, which fixes those problems but adds noise to the final result. To embrace the noise, I added a post-processing effect that adds some noise to everything.<\/p>\n\n\n\n<figure class=\"wp-block-embed is-type-video is-provider-vimeo wp-block-embed-vimeo\"><div class=\"wp-block-embed__wrapper\">\n<iframe loading=\"lazy\" title=\"Ferrofluid - Using noise to lower sampling count\" src=\"https:\/\/player.vimeo.com\/video\/1016954271?dnt=1&amp;app_id=122963\" width=\"422\" height=\"750\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\"><\/iframe>\n<\/div><figcaption class=\"wp-element-caption\">Lowering the sample count caused noise<\/figcaption><\/figure>\n\n\n\n<p>I tweaked the parameters more and removed the &#8220;asteroids&#8221; to get a better visual result, and here I got. This version runs smoothly on an iPhone 15 but it&#8217;s still way far from a production-ready state.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"410\" src=\"https:\/\/armintajik.com\/wp-content\/uploads\/2024\/06\/image-1-1024x410.png\" alt=\"\" class=\"wp-image-614\" srcset=\"https:\/\/armintajik.com\/wp-content\/uploads\/2024\/06\/image-1-1024x410.png 1024w, https:\/\/armintajik.com\/wp-content\/uploads\/2024\/06\/image-1-300x120.png 300w, https:\/\/armintajik.com\/wp-content\/uploads\/2024\/06\/image-1-768x307.png 768w, https:\/\/armintajik.com\/wp-content\/uploads\/2024\/06\/image-1-1536x614.png 1536w, https:\/\/armintajik.com\/wp-content\/uploads\/2024\/06\/image-1-2048x819.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<pre class=\"wp-block-code\"><code>float MetaballSDF(float3 curpos, float4 Sphere1, float4 Sphere2, float4 Sphere3, float k)\n{\n    float s1 = length(curpos - Sphere1.xyz) - Sphere1.w;\n    float s2 = length(curpos - Sphere2.xyz) - Sphere2.w;\n    float s3 = length(curpos - Sphere3.xyz) - Sphere3.w;\n\n    float h  = saturate(0.5 + 0.5 * (s2 - s1) \/ k);\n    float m12 = lerp(s2, s1, h) - k * h * (1.0 - h);\n\n    h  = saturate(0.5 + 0.5 * (s3 - m12) \/ k);\n    float m123 = lerp(s3, m12, h) - k * h * (1.0 - h);\n\n    return m123;\n}<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>static const int   kMaxStepsPrimary   = 32;\nstatic const int   kMaxStepsShadow    = 48;\nstatic const float kHitEps            = 1e-3;\nstatic const float kMinStep           = 0.01;\nstatic const float kMaxDist           = 200.0;\nstatic const float kShadowMaxDist     = 80.0;\nstatic const float kShadowStepScale   = 0.9;\n\ninline float SDF(float3 p)\n{\n    return CustomExpression0(Parameters, p, k, Sphere1, Sphere2, Sphere3, t);\n}\n\ninline float3 TetraNormal(float3 p, float eps)\n{\n    float3 e = float3(eps, eps, eps);\n    float d1 = SDF(p + e.xyy);\n    float d2 = SDF(p + e.yxy);\n    float d3 = SDF(p + e.yyx);\n    float d4 = SDF(p - e.xxx);\n    return normalize(float3(d1 - d4, d2 - d4, d3 - d4));\n}\n\nfloat4 MetaballsRay(float3 WorldPos, float3 CamPos, float thresh, float ShadowMult, float o, float Refraction)\n{\n    float3 rayDir = normalize(WorldPos - CamPos);\n    float3 curPos = WorldPos;\n    float tAccum  = 0.0;\n\n    &#91;loop]\n    for (int i = 0; i &lt; kMaxStepsPrimary; ++i)\n    {\n        float d = SDF(curPos);\n\n        if (d &lt; thresh)\n        {\n            float3 N = TetraNormal(curPos, o);\n            float3 refrDir = normalize(lerp(rayDir, -N, Refraction));\n\n            float accum = 0.0;\n            float shadowT = 0.0;\n            float3 sPos = curPos;\n\n            &#91;loop]\n            for (int j = 0; j &lt; kMaxStepsShadow; ++j)\n            {\n                if (shadowT > kShadowMaxDist) break;\n\n                float sd = SDF(sPos);\n\n                if (sd &lt; ShadowMult)\n                {\n                    sd = max(sd, 0.0);\n                    accum += (ShadowMult - sd);\n                    if (accum >= ShadowMult * 1.5) break;\n                }\n\n                float stepLen = max(kMinStep, sd) * kShadowStepScale;\n                sPos += refrDir * stepLen;\n                shadowT += stepLen;\n            }\n\n            return float4(N, accum);\n        }\n\n        float stepLen = max(kMinStep, d);\n        curPos += rayDir * stepLen;\n        tAccum += stepLen;\n        if (tAccum > kMaxDist) break;\n    }\n\n    return float4(0,0,0,0);\n}<\/code><\/pre>\n\n\n\n<figure class=\"wp-block-embed is-type-video is-provider-vimeo wp-block-embed-vimeo\"><div class=\"wp-block-embed__wrapper\">\n<iframe loading=\"lazy\" title=\"Ferrofluid - Improved version\" src=\"https:\/\/player.vimeo.com\/video\/1016954304?dnt=1&amp;app_id=122963\" width=\"422\" height=\"750\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\"><\/iframe>\n<\/div><\/figure>\n\n\n\n<p>While it was a great experiment, I parked the idea here to work on more exciting stuff!<\/p>\n\n\n\n<p><\/p>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>This is an attempt to prototype a mobile game idea that uses magnetic forces and fluid simulation inspired by ferrofluids. I chose Unreal Engine 5 for the task. After setting up the project repo using Perforce and preparing the build pipeline for iOS, I was ready to make the prototype. I started with the magnetic [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":564,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[11,13],"tags":[],"class_list":["post-490","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog","category-random"],"_links":{"self":[{"href":"https:\/\/armintajik.com\/index.php\/wp-json\/wp\/v2\/posts\/490","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/armintajik.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/armintajik.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/armintajik.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/armintajik.com\/index.php\/wp-json\/wp\/v2\/comments?post=490"}],"version-history":[{"count":24,"href":"https:\/\/armintajik.com\/index.php\/wp-json\/wp\/v2\/posts\/490\/revisions"}],"predecessor-version":[{"id":615,"href":"https:\/\/armintajik.com\/index.php\/wp-json\/wp\/v2\/posts\/490\/revisions\/615"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/armintajik.com\/index.php\/wp-json\/wp\/v2\/media\/564"}],"wp:attachment":[{"href":"https:\/\/armintajik.com\/index.php\/wp-json\/wp\/v2\/media?parent=490"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/armintajik.com\/index.php\/wp-json\/wp\/v2\/categories?post=490"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/armintajik.com\/index.php\/wp-json\/wp\/v2\/tags?post=490"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}