<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>BulletComment on Kasuri Works Blog</title>
    <link>https://kasuri.works/tags/bulletcomment/</link>
    <description>Recent content in BulletComment on Kasuri Works Blog</description>
    <generator>Hugo -- 0.147.6</generator>
    <language>en</language>
    <lastBuildDate>Fri, 30 May 2025 14:32:08 -0700</lastBuildDate>
    <atom:link href="https://kasuri.works/tags/bulletcomment/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Building Video Danmaku in Godot 4</title>
      <link>https://kasuri.works/posts/building-danmaku-system/</link>
      <pubDate>Fri, 30 May 2025 14:32:08 -0700</pubDate>
      <guid>https://kasuri.works/posts/building-danmaku-system/</guid>
      <description>A deep dive into the modular danmaku comment system design for a narrative-driven action game built in Godot 4 with C#.</description>
      <content:encoded><![CDATA[<p>One of the most emotionally expressive features I’ve been building recently in <strong>Hello, World. Goodbye.</strong> is a <em>danmaku-style</em> comment system — a tribute to NicoNico’s bullet comments and a way for players to silently scream into the void, or listen to what others once shouted.</p>
<h2 id="-goal">🎯 Goal</h2>
<p>Design a modular, smooth, high-performance danmaku (弾幕 / bullet comment) system that:</p>
<ul>
<li>Works independently of the main game camera (via a dedicated Viewport)</li>
<li>Supports multiple content types (text, image)</li>
<li>Distributes danmaku across tracks evenly or with focused styles</li>
<li>Dynamically calculates spacing and avoids overlapping</li>
</ul>
<h2 id="-step-by-step-construction">🎬 Step-by-Step Construction</h2>
<h3 id="step-1-start-simple">Step 1: Start Simple</h3>
<p>We began with a simple <code>RichTextLabel</code>-based danmaku node, moving from right to left:</p>
<p><img alt="Damaku Scene" loading="lazy" src="/posts/building-danmaku-system/danmaku-scene.en.png#center"></p>
<p>Then in the C# code attached add the following.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c#" data-lang="c#"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">override</span> <span style="color:#66d9ef">void</span> _Process(<span style="color:#66d9ef">double</span> delta) {
</span></span><span style="display:flex;"><span>    Position -= <span style="color:#66d9ef">new</span> Vector2(Speed * (<span style="color:#66d9ef">float</span>)delta, <span style="color:#ae81ff">0</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (Position.X + GetWidth() &lt; <span style="color:#ae81ff">0</span>) QueueFree();
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This worked fine until we realized something odd — the danmaku appeared offset and at (width, 0) it still appears to be from somewhere odd.</p>
<p><img alt="First Attempt Animation" loading="lazy" src="/posts/building-danmaku-system/first-attempt.en.gif#center"></p>
<p>This danmaku not only is not playing from the correct location, it is also disappearing way too early. Why is that? It appears the player has a camera attached, and because of that, what we see on the screen does not map 1:1 to the real location. This is a problem.</p>
<p><img alt="Player Camera Tree" loading="lazy" src="/posts/building-danmaku-system/player-camera.en.png#center"></p>
<h3 id="step-2-decouple-from-camera">Step 2: Decouple from Camera</h3>
<p>To solve this, we moved the danmaku system to a separate Viewport layer (640x360), unaffected by the main camera. Now comments would always start from the right edge of the screen, regardless of world movement.</p>
<p><img alt="Danmaku Viewport" loading="lazy" src="/posts/building-danmaku-system/danmaku-viewport.en.png#center"></p>
<blockquote>
<p>Question: How Danmaku is distributed on the screen?</p></blockquote>
<p>Using this example concert video on Bilibili, we can see a bunch of tracks on the screen with rolling Danmaku from right to left.</p>
<p><img alt="Example Miku Concert Danmaku - Bilibili" loading="lazy" src="/posts/building-danmaku-system/danmaku-example.en.png#center"></p>
<p>We can view the entire Danmaku as distributed in invisible tracks on the screen, as shown below.</p>
<p><img alt="Danmaku Tracks" loading="lazy" src="/posts/building-danmaku-system/danmaku-example-track.en.png#center"></p>
<h3 id="step-3-add-track-manager">Step 3: Add Track Manager</h3>
<p>Next, we introduced a <code>DanmakuTrackManager</code> to manage rows (or tracks) and ensure danmaku didn’t overlap.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c#" data-lang="c#"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">DanmakuTrackManager</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">readonly</span> List&lt;List&lt;DanmakuTrackEntry&gt;&gt; _tracks;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">readonly</span> Node _danmakuLayer;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">int</span> _trackCount;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">float</span> _trackHeight;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">float</span> _screenWidth;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">float</span> _spacing;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">bool</span> IsTrackAvailable(<span style="color:#66d9ef">int</span> trackIndex)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> track = _tracks[trackIndex];
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (track.Count == <span style="color:#ae81ff">0</span>) <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> last = track[^<span style="color:#ae81ff">1</span>];
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">float</span> lastRight = last.Entry.Position.X + last.Entry.Call(<span style="color:#e6db74">&#34;GetWidth&#34;</span>).AsSingle();
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> lastRight + <span style="color:#ae81ff">20f</span> &lt; _screenWidth;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The system auto-calculates track count and spacing based on the size of a sample danmaku (like <code>RichTextDanmakuLabel</code>).</p>
<h3 id="step-4-add-distributor">Step 4: Add Distributor</h3>
<p>We then implemented <code>IDanmakuTrackDistributor</code> to control how tracks are assigned. Two distribution modes were added:</p>
<p>Even Distribute: Round-robin fill</p>
<p>Focus Few: Stack into fewer rows for visual intensity</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c#" data-lang="c#"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">EvenDistributor</span> : IDanmakuTrackDistributor
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">int</span> GetTrackIndex(<span style="color:#66d9ef">int</span> danmakuCount, <span style="color:#66d9ef">int</span> trackCount)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> danmakuCount % trackCount;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This is a simple round-robin distributor that assigns danmaku to tracks evenly. But in reality, we need to access the track manager to check if the track is available.</p>
<h3 id="step-5-add-queue-system">Step 5: Add Queue System</h3>
<p>Normally when a bunch of danmaku is added, we want to queue them up and then distribute them all at once. This is useful for when we want to add a bunch of danmaku at once, like when the game system is triggered to add a bunch of danmaku at once.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c#" data-lang="c#"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">partial</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">DanmakuSystemController</span> : Node, ISystemModule
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> Queue&lt;DanmakuQueueEntry&gt; _danmakuQueue;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> DanmakuTrackManager _trackManager;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">double</span> _lastSpawnTime = <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">override</span> <span style="color:#66d9ef">void</span> _Ready()
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        _danmakuQueue = <span style="color:#66d9ef">new</span> Queue&lt;DanmakuQueueEntry&gt;();
</span></span><span style="display:flex;"><span>        _trackManager = <span style="color:#66d9ef">new</span> DanmakuTrackManager();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">void</span> AddDanmaku(DanmakuQueueEntry entry)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        _danmakuQueue.Enqueue(entry);
</span></span><span style="display:flex;"><span>        ProcessQueue();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">override</span> <span style="color:#66d9ef">void</span> _Process(<span style="color:#66d9ef">double</span> delta)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">if</span> (_danmakuQueue.Count == <span style="color:#ae81ff">0</span>) <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Peek queue and if time passed, spawn a danmaku</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (Time.GetTicksMsec() &gt; _lastSpawnTime + SpawnInterval)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// If the next track is available on the track manager</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> (_trackManager.HasTrackSpace())
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">var</span> danmakuData = _danmakuQueue.Dequeue();
</span></span><span style="display:flex;"><span>                _trackManager.AddDanmakuToTrack(danmakuData.Data);
</span></span><span style="display:flex;"><span>                _lastSpawnTime = Time.GetTicksMsec();
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This code allows us to queue up danmaku and process them in the <code>_Process</code> method, checking if the track manager has space before spawning. If the track is available, it will dequeue the danmaku and add it to the track manager.</p>
<h3 id="step-5-fix-overrun-issue">Step 5: Fix Overrun Issue</h3>
<p>There is one issue, because the danmaku are using random speeds, faster danmaku would overtake slower ones in the same track, causing overlaps. The same effect is also shown on the Bilibili example above. The fix was to introduce a &ldquo;speed clip&rdquo; to ensure that the danmaku speed does not exceed the maximum speed of all danmakus in the track.</p>
<p>The following code is added to the <code>DanmakuTrackManager</code> to check if the track is available:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c#" data-lang="c#"><span style="display:flex;"><span><span style="color:#75715e">// Set initial speed, cap the speed to prevent overrun</span>
</span></span><span style="display:flex;"><span>danmakuData.Speed = Math.Max(GetMaxSpeedInTrack(trackIndex), danmakuData.Speed);
</span></span></code></pre></div><p>And then we need to implement the <code>GetMaxSpeedInTrack</code> method to get the maximum speed in the track.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c#" data-lang="c#"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">float</span> GetMaxSpeedInTrack(<span style="color:#66d9ef">int</span> trackIndex)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> track = _tracks[trackIndex];
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (track.Count == <span style="color:#ae81ff">0</span>) <span style="color:#66d9ef">return</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Loop through track to get max speed</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> track.Max(entry =&gt; entry.Entry.Speed);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="step-6-tieing-it-all-together">Step 6: Tieing It All Together</h3>
<p>Now we talked about how to implement <code>DanmakuTrackManager</code>, <code>IDanmakuTrackDistributor</code>, <code>DanmakuSystemController</code> and the <code>RichTextDanmakuLabel</code>,
we can now tie it all together in the main game scene.</p>
<p><img alt="Danmaku System Diagram" loading="lazy" src="/posts/building-danmaku-system/system-diag.en.png#center"></p>
<h3 id="final-result">Final Result</h3>
<p><img alt="Final Danmaku Result" loading="lazy" src="/posts/building-danmaku-system/final-result.en.gif#center"></p>
<h3 id="conclusion">Conclusion</h3>
<p>The danmaku system is now modular, efficient, and easy to extend. It supports different content types, distributes comments across tracks as needed, and manages spacing to prevent overlaps.</p>
<p>This approach keeps the codebase organized and maintainable, making it easier to add new features or tweak behavior later. Hopefully, this walkthrough gives you a solid starting point for building your own danmaku system in Godot 4 with C#. If you have questions or ideas, let me know—happy coding!</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
