WPFのRenderingイベントでFPSを表示するサンプルコード2「アニメーション」

コンピュータ

CompositionTarget.Renderingを使いキャラクタが動くサンプルコードを作成しました。

ソースコード

ファイル名:WalkAnime.csproj


<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
  </PropertyGroup>

</Project>

ファイル名:MainWindow.xaml.cs

<Window x:Class="WalkAnime.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WalkAnime"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid Background="Black">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>

        <TextBlock x:Name="FpsText"
                   Grid.Row="0"
                   Foreground="Lime"
                   FontSize="20"
                   Margin="8"
                   HorizontalAlignment="Left"/>

        <Image x:Name="GameImage"
               Grid.Row="1"
               Stretch="Uniform"
               HorizontalAlignment="Center"
               VerticalAlignment="Center"/>
    </Grid>
</Window>

ファイル名:MainWindow.xaml.cs

using System.Diagnostics;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;

namespace WalkAnime;

public partial class MainWindow : Window
{

    const int SCR_WIDTH  = 640;
    const int SCR_HEIGHT = 400;

    // 固定フレーム(60FPS)
    const double FIXED_DT = 1.0 / 60.0;

    // ダブルバッファ
    private readonly WriteableBitmap[] _buffers =
    {
        new(SCR_WIDTH, SCR_HEIGHT, 96, 96, PixelFormats.Bgra32, null),
        new(SCR_WIDTH, SCR_HEIGHT, 96, 96, PixelFormats.Bgra32, null),
    };

    private int _bufferIndex = 0;
    private WriteableBitmap BackBuffer => _buffers[_bufferIndex % 2];
    private WriteableBitmap FrontBuffer => _buffers[(_bufferIndex + 1) % 2];

    // 背景色
    private readonly byte[] _bgColor = { 0, 128, 0, 255 }; // BGRA

    // 時間計測用
    private readonly Stopwatch _stopwatch = new();
    private double _prevTimeSec = 0.0;
    private double _accumulator = 0.0;

    // FPS表示用(描画側のFPS)
    private double _renderFps = 0.0;

    // キャラ状態
    private double _playerX = SCR_WIDTH / 2.0;
    private double _playerY = SCR_HEIGHT - 40;
    private double _playerSpeed = 60.0;   // px/sec(ゲームロジックは60FPS固定)
    private int _animCounter = 0;
    private double _animElapsed = 0.0;
    public MainWindow()
    {
        InitializeComponent();
        GameImage.Source = FrontBuffer;

        _stopwatch.Start();
        _prevTimeSec = 0.0;

        CompositionTarget.Rendering += OnRendering;
    }

    private void OnRendering(object? sender, EventArgs e)
    {
        // 経過時間(実時間)
        double nowSec = _stopwatch.Elapsed.TotalSeconds;
        double frameDt = nowSec - _prevTimeSec;
        _prevTimeSec = nowSec;

        // 描画FPS計測(オマケ)
        if (frameDt > 0)
            _renderFps = 1.0 / frameDt;

        // 固定ステップ用に貯める
        _accumulator += frameDt;

        // 「重すぎて dt が跳ねた」場合の保険(無限ループ防止)
        const int MAX_STEPS = 5;
        int steps = 0;

        // FIXED_DT(1/60秒)ごとにロジック更新
        while (_accumulator >= FIXED_DT && steps < MAX_STEPS)
        {
            UpdateFixed(FIXED_DT);
            _accumulator -= FIXED_DT;
            steps++;
        }

        // 描画は1回だけ(現在の状態をそのまま描く)
        DrawScene(BackBuffer);

        _bufferIndex++;
        GameImage.Source = FrontBuffer;

        FpsText.Text = $"Render: {_renderFps:0.0} FPS  (Logic: 60 FPS)";
    }

    // ---------------- 固定ステップのゲームロジック ----------------

    private void UpdateFixed(double dt)
    {
        // 左→右へ一定速度で歩く(60FPSでも144FPSでも同じゲーム速度)
        _playerX += _playerSpeed * dt;
        if (_playerX > SCR_WIDTH + 20)
            _playerX = -20;

        // 歩きアニメ(0.1秒ごとに足のパターンを切り替え)
        _animElapsed += dt;
        if (_animElapsed >= 0.1)
        {
            _animElapsed -= 0.1;
            _animCounter++;
        }
    }

    // ---------------- 描画 ----------------

    private void DrawScene(WriteableBitmap wb)
    {
        int stride = SCR_WIDTH * 4;
        byte[] pixels = new byte[SCR_HEIGHT * stride];

        // 背景クリア
        for (int i = 0; i < pixels.Length; i += 4)
        {
            pixels[i + 0] = _bgColor[0];
            pixels[i + 1] = _bgColor[1];
            pixels[i + 2] = _bgColor[2];
            pixels[i + 3] = 255;
        }

        // 地面ライン
        DrawRect(pixels, stride, 0, SCR_HEIGHT - 10, SCR_WIDTH, 10, 0, 0, 0);

        // キャラクタ
        DrawPlayer(pixels, stride);

        wb.WritePixels(new Int32Rect(0, 0, SCR_WIDTH, SCR_HEIGHT), pixels, stride, 0);
    }

    private void DrawPlayer(byte[] pixels, int stride)
    {
        int wBody = 16;
        int hBody = 24;
        int wLeg  = 6;
        int hLeg  = 10;

        int centerX = (int)_playerX;
        int baseY   = (int)_playerY;

        // 体
        int bodyX = centerX - wBody / 2;
        int bodyY = baseY - hBody - hLeg;
        DrawRect(pixels, stride, bodyX, bodyY, wBody, hBody, 0, 0, 255);

        // 頭
        int headSize = 10;
        int headX = centerX - headSize / 2;
        int headY = bodyY - headSize;
        DrawRect(pixels, stride, headX, headY, headSize, headSize, 255, 224, 192);

        // 足(2コマ切り替え)
        bool frameEven = (_animCounter % 2) == 0;

        if (frameEven)
        {
            // 左足前、右足後ろ
            DrawRect(pixels, stride,
                     centerX - wLeg - 2, baseY - hLeg,
                     wLeg, hLeg, 0, 0, 0);

            DrawRect(pixels, stride,
                     centerX + 2, baseY - hLeg,
                     wLeg, hLeg, 0, 0, 0);
        }
        else
        {
            // 右足前、左足後ろ
            DrawRect(pixels, stride,
                     centerX - wLeg - 2, baseY - hLeg,
                     wLeg, hLeg, 0, 0, 0);

            DrawRect(pixels, stride,
                     centerX + 2 + 4, baseY - hLeg,
                     wLeg, hLeg, 0, 0, 0);
        }
    }

    /// <summary>単色矩形描画(BGRA)</summary>
    private static void DrawRect(
        byte[] pixels,
        int stride,
        int x,
        int y,
        int w,
        int h,
        byte r,
        byte g,
        byte b)
    {
        for (int yy = 0; yy < h; yy++)
        {
            int py = y + yy;
            if (py < 0 || py >= SCR_HEIGHT) continue;

            int rowStart = py * stride;

            for (int xx = 0; xx < w; xx++)
            {
                int px = x + xx;
                if (px < 0 || px >= SCR_WIDTH) continue;

                int idx = rowStart + px * 4;
                pixels[idx + 0] = b;
                pixels[idx + 1] = g;
                pixels[idx + 2] = r;
                pixels[idx + 3] = 255;
            }
        }
    }
}

実行

実行すると、矩形で書かれたキャラクタが左から右へ移動するアニメーションが表示されます。

60FPS固定ルーチン

_accumulator … 直前のOnRenderingからの経過時間
FIXED_DT … 1フレームの時間 1.0/60

// 固定ステップ用に貯める
_accumulator += frameDt;

_accumulatorに経過時間を加算

while ( _accumulator >= FIXED_DT ... 

_accumulator直前のループからFIXED_DT以上経過した場合{}無いの処理を行う。

UpdateFixed(FIXED_DT);

更新描画処理。ここが秒間60回呼ばれるようにするのが目的

_accumulator -= FIXED_DT;

_accumulatorからFIXED_DTを減算することでループを抜け、次のOnRenderingに備える。

このルーチンにより144Hzのモニターでも60FPSで動作するようになります。タイミングを調整するために待ち処理を行っていいるので、CPU時間的には効率が悪いです。タイミングが合わない場合、メソッドを抜けるコードにすることも考えられますが、イベントドリブンの精度を考えると、今回の方が現実的だと思います。

ちなみにstep関係のルーチンは、処理落ちが発生した場合、無限ループにならないようにする為の機構です。

コメント