【Rockman2 hack】ファミコン規格外のラスタースクロールは再現できるのか?

C/C++

Windowsにファミコン版ロックマンを移植して、更にハックロム的作成をしていきましょうというプロジェクトです。

今回はちょっと面白いソースコードを作ったのでご紹介したいなと思います。

ファミコンに詳しい方は知っているかもしれない「ラスタースクロール」に関してです。

これは当時からよくゲーム業界でしばしば散見された言葉で、
例えば、上下二分割された画面上で、上だけスクロールするとか、はたまた上と下それぞれ違う速度で画面スクロールするとか、そんな技法の事を指しています。

メガドライブ(Mega Drive)なんかは、行単位にスクロール指定できるそうですが、ファミコンはそんな機能なかったので、色々裏で頑張ってたみたい。

そういった事ができる事は確かです。
それは例えば「ロックマン6」トマホークマンステージの夕陽がバックに映るエリアなどで見る事ができます。

まあそんなわけで、今回はそのラスタースクロールを実装してみました。


【ファミコン】サマーカーニバル’92烈火【後期】

まずはこれを見てください。

これを知ってる人はおそらく相当なファミコン好きですね・・・f( ̄▽ ̄;)

1992年に開発元KID、ナグザットから発売された「サマーカーニバル’92烈火」です。
今都内で数十万円の値段で販売されているという激レアソフト。

矢川忍という方がプログラマーだと書かれています。
矢川さんはこのゲームを作った後、会社を作りそこでこのゲームを元に作成したシューティング「バトルガレッガ」が名作として語られている方です。

このゲームではありとあらゆる面で「異常性」を持っていて、その中でも特に印象的なのがファミコンで実現できないと思わしきスクロール表現です。

上記の動画を見ていただければお分かりだと思いますが、画面スクロールがとにかく早く、中でも1面後半のぐにゃぐにゃする多重ラスタースクロール背景は必見です。

これ本当にファミコン?と思わせる異色の作品なのですが、当時は既にスーパーファミコンでドラクエⅤやFFⅤ、スーパーマリオカートなどバカ売れした作品が発売している頃であり、既にファミコンの新作は店頭に並んでもお客さんはスルーするだけだった事でしょう。

ファミコン後期の作品は、音楽や限界を超えたと言われるプログラムテクノロジーが満載で、マニアの方からすると垂涎モノである事は間違いありません。

しかし当時からその存在に気付いていた人は少ないでしょう。

ゲーマーの皆さんの興味は既に16bit機で、ファミコンは今でいうオワコン的存在になっていましたが、開発者の方々の闘志はまだ健在だった、という事でしょうね。

そんなこんなで2000年を超えてから再評価されたこのゲームソフトなんですが、私もファミコンゲームを開発している身として気になるわけです(^^)

という事で、今回はこの烈火の激アツスクロール技術を模倣していこうと思います。


Implementation

今回は “C++20” に対応したソースコードという前提で、コードを作成していきます。

一応前提条件ですが、

...
    while (!::DxLib::ProcessMessage() && !::DxLib::SetDrawScreen(_screenHandle) && !::DxLib::ClearDrawScreen())
    {
        _time->BeginFrame();    // Defines delta time for this frame.

        auto destW = static_cast<int>(SystemConfig::kScreenWidth * _viewerRate);
        auto destH = static_cast<int>(SystemConfig::kScreenHeight * _viewerRate);

        // If the game is paused, we skip the update logic.
        PauseManager::SetPaused(GameStateManager::GetInstance().Is(GameState::Paused));

        // --- Go game update & rendering ---
        seq.Update();                                   // Update the main sequence.
        seq.RenderWorld(_screenHandle, destW, destH);   // Render the game content, and extended rate scaling.
        seq.RenderOverlay(destW, destH);                // Render the overlay content (e.g., HUD, debug information).

        // Render the input configuration overlay if active.
        seq.HandleJpbtnConfigMode(static_cast<double>(_time->DeltaSeconds()));

        // Pace & Flip the screen.
        fps.Wait();
        // VSync control = pseudo FPS with monitor refresh rate.
        if (_vSync) ::DxLib::WaitVSync(1);
        // Screen flip to present the rendered frame of the back buffer.
        ::DxLib::ScreenFlip();

        _time->EndFrame();      // End of frame processing.
    }
...

全体の構成は、

スクリーンバッファークリア();
内部計算処理();
画面描画処理();
fps.VSyncCtrl();
スクリーンバッファーの反転();

この状況とします。

上記のコードは DxLib.h を使用しています。
DXライブラリを使用する前提です。


// BgWobble2D.h
#include <vector>

// A special effects that applies a pseudo-LissajousCurve scrolling effect to the background (BG) layer
class BgWobble2D
{
public:
    BgWobble2D() = default;
    ~BgWobble2D() = default;

    // logical_h : logical screen height (e.g. 240)
    // stripe_h  : 1..4 (1 = per scanline; 2~4 for performance)
    void Initialize(int logical_h, int stripe_h) noexcept;

    // amplitudes in px; freq is kept for compatibility (unused); speed=rad/sec; phase0=rad
    // edge_atten is also kept for compatibility (unused)
    void SetParams(float amp_x_px, float amp_y_px, float freq, float speed, float phase0, float edge_atten) noexcept;

    // Raster vertical band phase delay settings
    // band_h_px        : e.g. 16 (0/negative is invalid = no band delay)
    // phase_step_rad   : phase added for each band up (rad)
    // bottom_to_top    : true=delay from bottom to top, false=top to bottom
    void SetRasterBanding(int band_h_px, float phase_step_rad, bool bottom_to_top) noexcept;

    // Update internal state with delta time in seconds
    void Update(float dt_sec) noexcept;

    // Copy BG from src_handle (logical size src_w*src_h) to BACKBUFFER with wobble and scaling
    void Render(int src_handle, int src_w, int src_h, int dst_w, int dst_h, float dst_x = 0.0f, float dst_y = 0.0f) noexcept;

private:
    int   _stripe_h{ 2 };       // 1..4 (1=per scanline; 2~4 for performance)
    int   _logical_h{ 0 };      // e.g. 240

    float _amp_x_px{ 14.0f };
    float _amp_y_px{ 2.0f };
    float _freq_y{ 0.0f };      // unused (kept for compatibility)
    float _speed{ 2.6f };
    float _phase{ 0.0f };
    float _edge_atten{ 0.10f }; // unused (kept for compatibility)

    // Raster vertical band delay
    int   _band_h_px{ 16 };     // 16px per band
    float _band_phase_step{ 0.0f };
    bool  _band_bottom_to_top{ true };

    std::vector<float> _offx;   // per stripe X offset
    std::vector<int>   _srcy;   // per stripe source Y

    // U-shaped profile (center 0, max at top/bottom)
    [[nodiscard]] float UParabola(float v01) const noexcept;
};
// BgWobble2D.cpp
#include "BgWobble2D.h"

#include <cmath>
#include <numbers>

using std::clamp;

void BgWobble2D::Initialize(int logical_h, int stripe_h) noexcept
{
    _logical_h = logical_h;
    _stripe_h = (stripe_h <= 0) ? 1 : stripe_h;

    const int stripes = (_logical_h + _stripe_h - 1) / _stripe_h;
    _offx.assign(static_cast<std::size_t>(stripes), 0.0f);
    _srcy.assign(static_cast<std::size_t>(stripes), 0);
}

void BgWobble2D::SetParams(float ax, float ay, float freq, float spd, float phase0, float edge) noexcept
{
    _amp_x_px = ax;
    _amp_y_px = ay;
    _freq_y = freq;   // unused (kept for compatibility)
    _speed = spd;
    _phase = phase0;
    _edge_atten = clamp(edge, 0.0f, 1.0f); // unused (kept for compatibility)
}

void BgWobble2D::SetRasterBanding(int band_h_px, float phase_step_rad, bool bottom_to_top) noexcept
{
    _band_h_px = (band_h_px <= 0) ? 0 : band_h_px; // 0 is invalid
    _band_phase_step = phase_step_rad;
    _band_bottom_to_top = bottom_to_top;
}

void BgWobble2D::Update(float dt) noexcept
{
    _phase += _speed * dt;
    constexpr float two_pi = std::numbers::pi_v<float> *2.0f;
    if (_phase > two_pi || _phase < -two_pi)
    {
        _phase = std::fmod(_phase, two_pi);
    }
}

float BgWobble2D::UParabola(float v01) const noexcept
{
    // U(v) = 1 - 4*(v-0.5)^2, clamp to [0,1]
    const float d = v01 - 0.5f;
    const float w = 1.0f - 4.0f * d * d;
    return (w > 0.0f) ? w : 0.0f;
}

void BgWobble2D::Render(int src, int src_w, int src_h,
    int dst_w, int dst_h,
    float dst_x, float dst_y) noexcept
{
    if (src < 0 || src_w <= 0 || src_h <= 0 || dst_w <= 0 || dst_h <= 0) { return; }

    const int stripes = static_cast<int>(_offx.size());
    const float base_t = _phase;
    const float phi = std::numbers::pi_v<float> *1.0f; // default: π (8-shaped)

    // Number of bands when the screen is divided into vertical stripes (0 means no delay)
    const int band_h = (_band_h_px > 0) ? _band_h_px : _logical_h; // invalid = whole area 1 band
    const int bands = (band_h > 0) ? ((_logical_h + band_h - 1) / band_h) : 1;

    for (int i = 0; i < stripes; ++i)
    {
        const int y0 = i * _stripe_h;
        const float v = static_cast<float>(y0) / static_cast<float>(_logical_h); // 0..1
        const float uW = UParabola(v);

        // ----- Band index and phase -----
        const int band_idx_from_top = y0 / band_h;               // 0=topmost
        const int band_idx_from_bottom = (bands - 1) - band_idx_from_top;

        const int band_idx = _band_bottom_to_top
            ? band_idx_from_bottom  // bottom-to-top: lower has younger phase (starts moving first)
            : band_idx_from_top;    // top-to-bottom

        const float t_band = base_t + _band_phase_step * static_cast<float>(band_idx);

        // ----- Like a Lissajous curve: U-shaped X (sin, 2sin) -----
        const float offx = _amp_x_px * std::sinf(t_band) * uW;
        const float offy = _amp_y_px * std::sinf(2.0f * t_band + phi) * uW;

        _offx[static_cast<std::size_t>(i)] = offx;

        int sy = y0 + static_cast<int>(std::lround(offy));
        sy = std::clamp(sy, 0, src_h - std::min(_stripe_h, src_h));
        _srcy[static_cast<std::size_t>(i)] = sy;
    }

    // ---- logical->backbuffer scale ----
    const float sx = static_cast<float>(dst_w) / static_cast<float>(src_w);
    const float sy = static_cast<float>(dst_h) / static_cast<float>(src_h);

    // ---- draw stripes ----
    for (int i = 0; i < stripes; ++i)
    {
        const int y0 = i * _stripe_h;
        const int h = (y0 + _stripe_h <= src_h) ? _stripe_h : (src_h - y0);
        if (h <= 0) { continue; }

        const float ox = _offx[static_cast<std::size_t>(i)];
        const int sySrc = _srcy[static_cast<std::size_t>(i)];

        const float dx1 = dst_x + ox * sx;
        const float dy1 = dst_y + static_cast<float>(y0) * sy;
        const float dx2 = dx1 + static_cast<float>(src_w) * sx;
        const float dy2 = dy1 + static_cast<float>(h) * sy;

        // Draw the stripe with wobble effect
        ::DxLib::DrawRectGraphF(dx1, dy1, 0, sySrc, src_w, h, src, TRUE);
    }
}

これが、メインのBGエフェクト本体になります。

基本的に一枚絵のグラフィックを前提に制作しています。
Updateメソッドで位相の更新をして、Renderメソッドで描画するようになります。

更新処理と描画処理を分割している場合は、上記の処理をそれぞれに含める事で動きます。

使い方は、

// void Initialize(int logical_h, int stripe_h)
// ラスタースクロールエフェクトを使うための初期化処理。
BgWobble2D::Initialize(240, 2);

まず最初に描画するウィンドウの高さとストライプ配列の数を指定します。
ストライプ配列[stripe_h] は例えば 1 の場合滑らかに動くようになり 4 の場合は処理が少し軽量になります。

// void SetParams(float amp_x_px, float amp_y_px, float freq_y, float speed, float phase0, float edge_atten)
// 各種閾値のセット。
BgWobble2D::SetParams(
    /*amp_x_px*/ 27.0f,
    /*amp_y_px*/ 24.0f,
    /*freq_y  */ 0.0f,
    /*speed   */ 2.5f,
    /*phase0  */ 0.0f,
    /*edge    */ 0.2f
);

amp_x_pxとamp_y_pxは縦と横の移動量を指定します。

speedはスクロールの速度、edgeとfreq_yは使っていません(外してもよいです。私がこの機能を開発中に実験的実装をしただけで意味はありません笑)。

// void SetRasterBanding(int band_h_px, float phase_step_rad, bool bottom_to_top)
// 
BgWobble2D::SetRasterBanding(16, std::numbers::pi_v<float> / 19.9f, true);

これは、烈火の背景のようなギミックを再現するために必要なパラメータになります。

band_h_px = 16 は、うねうねする背景を分割する数。
phase_step_rad = pi_v<float> / 19.9f は、それぞれの分割した背景をずらすために仕掛けるディレイの時間。
bottom_to_top の真は下から上に画像をずらしていき、偽は上から下にずれていく。

float deltaTimeSec = static_cast<float>(fps.DeltaSeconds());
BgWobble2D::Update(deltaTimeSec);

Updateメソッドにデルタタイムを渡します。
これにより内部でスクロールを進行させます。

BgWobble2D::Begin();
bgTileManager.DrawTileById(_bgTileId, 0, 0, 0);  // BGデータレンダリング
BgWobble2D::EndAndCompositeToRT(screenHandle, screenW, screenH, deltaTimeSec);

Beginメソッドを行ってから、BGグラフィックを描画して、EndAndCompositeToRTメソッドを呼び出します。

この時、上記のように BGを描画しているグラフィックハンドルと高さ、幅、デルタタイム(Updateで渡したものと同じ)を渡します。

EndAndCompositeToRTメソッドが実行された時点で、ラスタースクロール処理が行われ、グラフィックが描画されます。

それでは以下詳細な解説です(・∀・)/


References

BgWobble2D 実装の狙いと構成

このクラスは、BG用のレンダーターゲット(論理解像度)に対して、U字プロファイル×ラスタ位相ディレイを用いた”疑似リサージュ風”スクロールを付与します。
設計上のポイントは次の3つです。

  1. 時間位相の明確な更新点
    Update(dt) で _phase を前進(rad/sec)。Render() から分離したことで、フレーム同期や可変FPS環境でも位相管理を一元化できます。2π でラップして位相の発散を防止。
  2. ラスタ位相ディレイ(縦帯ごと)
    SetRasterBanding(band_h_px, phase_step, bottom_to_top) により、画面を高さ band_h_px ピクセル単位の縦帯に分割。帯インデックスに比例して phase_step を加算し、下→上(または上→下)へ段階的に位相をずらすことで、帯が連なって“烈火的”な動きを作ります。
    式:t_band = base_t + phase_step * band_index

処理の流れ

  1. 入力チェック
    画像ハンドルやサイズがおかしければ即リターン。
  2. 位相とラスタ帯の下準備
    • base_t = _phase(Update()で進めた“時間位相”)
    • phi(縦用の位相ズラし。例:π)
    • 画面を縦方向に band_h_px ごとで分け、帯インデックスを計算。
      帯ごとに phase_step だけ時間位相を遅らせ(または進め)ます。
  3. ストライプ単位でオフセット計算
    画面を _stripe_h 行の水平ストライプに刻みます(1〜4px程度)。
    各ストライプ i について:
    • y0 = i * _stripe_h
    • v = y0 / logical_h(0..1 の高さ正規化)
    • U字プロファイル U(v) = max(0, 1 – 4*(v-0.5)^2) を計算
    • 帯位相 t_band = base_t + band_phase_step * band_index
    • 横オフ offx = A * sin(t_band) * U(v)
    • 縦オフ offy = B * sin(2*t_band + phi) * U(v)
    • ストライプのソースY sy = clamp(y0 + round(offy)) を決めて保存
  4. 拡大率の計算
    • sx = dst_w / src_w, sy = dst_h / src_h
      論理解像度→ウィンドウ解像度への拡大率。
  5. ストライプごとに矩形転送
    各ストライプを
    • X方向だけ offx を足して(横に撓ませる)
    • Y方向はソース矩形のYを sy に(縦スクロール効果)で DrawRectGraphF を呼び出し、バックバッファへ貼り付けます。
      これを上から下まで繰り返すと、”帯ごとに位相がズレたU/8字ループ”が合成されます。

キモになってる数式

  • U字プロファイル(上下強・中央弱の重み)
    U(v) = max(0, 1 - 4 * (v - 0.5)^2) // v ∈ [0,1]
  • ラスタ帯の位相ディレイ
    band_index = floor(y0 / band_h_px) // 縦16pxごと など
    t_band = base_t + band_phase_step * band_index
    • 下→上へディレイしたければ、インデックスの基準を“下から数える”に変更
  • 横/縦の変位
    offx = A * sin(t_band) * U(v)
    offy = B * sin(2 * t_band + φ) * U(v)
    sy = clamp(y0 + round(offy))
    • φ=π にすると“8の字寄り”の見え方を作りやすい

縦オフセットはソース矩形のsyをずらす(=縦スクロール効果)。
横は描画先Xをずらすことで”撓みたわみ“を表現。

結果として、帯ごとに開始タイミングが遅れるU/8字モーションが積層され、下から上へ流れる独特のBG動きになります。

APIと運用の勘所

  • Initialize(logical_h, stripe_h):ストライプ配列を確保。stripe_h=1..2 で最も滑らか、3..4 は軽量。
  • SetParams(ax, ay, /freq unused/, speed, phase0, /edge unused/):互換維持。axで∪/∩の深さ、ayで縦うねり、speedで流速感、phase0で開始形状を制御。
  • SetRasterBanding(16, π/6, true) あたりが“烈火寄り”の入り口。band_h_px を 8 にすると繊細、24 で大胆。
  • 拡大は上位で:本実装は 1:1 転送に固定。最終解像度へのスケーリングや他エフェクト合成(CRTカーブ等)は、呼び出し側のレンダーパスでまとめて行うと見通しが良いです。

そんな感じで実際に実装してみた動きが以下のようになりました。
完全再現は無理ですが、烈火のあの動きに似たのではないかと思います。

というわけで、ファミコンの技術の模倣はできますよ、という事が分かりました。はい。

しかしながらこれが今作成中の改造ロックマンに使用されるかは分かりません(笑)

要するに何をしたかったかと言いますと、Windowsで作っている状況下で、実現の可能性を広げるものとしてこれを実装してみたかったのです。

これが実際のファミコンロムで実装するとなるとね・・・
もう大変ですよこれが(´・ω・`;)
絶対実装できないでしょう。。

マッパー(容量)の問題もそうですが、何よりアセンブラ解読するの面倒。。。((´∀`*))アハハ

それが私がWindowsプラットフォーム開発をする大きな理由になっていたりもします。

まぁその話はまた・・・


【コラム】リサージュ曲線って何ですか?

幾何学的曲線の一つ。

リサージュ曲線(Lissajous curve)は、互いに直交する2つの単振動を合成することで描かれる美しい図形です。数学的には、以下のような媒介変数表示で表されます:

ここで、

  • ( A, B ):それぞれの振動の振幅
  • ( a, b ):振動数(周波数)
  • ( δ ):位相差
  • ( θ ):媒介変数(時間など)

この式からわかるように、振動数の比 ( a:b ) や位相差の値によって、描かれる図形の形状が大きく変化します。

振動数の比が有理数なら閉じた曲線になり、無理数なら軌道が平面上を密に埋め尽くすような図形になります。


実用例と魅力

リサージュ曲線は、かつてオシロスコープで波形の比較や周波数の測定に使われていました。X軸とY軸に異なる信号を入力すると、画面上にリサージュ図形が現れ、その形から周波数比や位相差を読み取ることができたのです。

また、数学的な美しさからアート作品ビジュアルデザインにも応用されており、複雑で調和の取れたパターンは見る者を魅了します。


ちょっとした豆知識

この曲線は、フランスの物理学者ジュール・アントワーヌ・リサージュによって考案されました。日本語では「リサジュー曲線」と表記されることもありますが、どちらも同じものを指します。

コメント

タイトルとURLをコピーしました