2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ReactAdvent Calendar 2024

Day 11

Reactでアニメーションの開始・ステップ実行・一時停止・リセットを制御する

Last updated at Posted at 2024-12-10

これは何?

image.png

  • Reactのコンポーネントで、アニメーションなどの何らかのステップごとの処理内容を、開始・ステップ実行・一時停止・リセットの4つの機能で制御するしくみと、これらの機能に対応したボタンなどで構成されたユーザインターフェイスを提供します。

  • アニメーションの1フレーム分を実行するtick関数を、コンポーネント外部からプロパティで与えることができます。このtick関数は、requestAnimationFrame関数を通じて、周期的な呼び出しをされます。

  • tick関数を実行する周期は、「亀(ゆっくり)」と、「ウサギ(すばやく)」のスライダーで制御できます。

  • コンポーネントの状態として、経過時間、フレーム番号、FPSを管理し、表示します。

  • React Material UI Componentを用いています。

  • MITライセンスです。

デモ

本記事のコンポーネントを内部的に利用して、シェリングの分居モデルのシミュレーションを開発しました。tick関数から、JavaScript / WebAssembly / WebGPU / WebGPU + WebAssembly の4通りの実装を実行して、それぞれの動作速度を比較することができます。

ちなみに、MacBook Pro 16インチ(M1 Max)で、1024x1024のトーラス状の空間の75%に5種類のエージェントを配置してシミュレーションを動作させた場合、 WebGPU + WebAssemblyの組み合わせで、90fps弱くらいまで性能を引き出すことができました。

image.png

機能

  • このコンポーネントの状態は、INITIALIZING(初期化中), INITALIZED(初期化済み), RUNNING(実行中), STEP_RUNNING(ステップ実行中), PAUSED(一時停止中)の5つの値を取ります。ユーザの操作内容や、tick関数の返値によって状態を遷移させます。
  • 開始ボタン、ステップ実行ボタン、一時停止ボタン、リセットボタンを押したときにコールバックされる関数を、コンポーネント外部からプロパティで与えることができます。
  • FPSは、一時停止をしていた時間を除いて計算されるようになっています。

使い方

import {
  PlayControllerPanel,
  PlayControllerState,
} from './PlayControllerPanel';

:

:
  const [playControllerState, setPlayControllerState] =
    useState<PlayControllerState>(PlayControllerState.INITIALIZING);

  const onReset = useCallback(() => { 
    // TODO: リセット処理
  }, []);
  
  const onPause = useCallback(() => {
    setPlayControllerState(PlayControllerState.PAUSED);
  }, []);
  
  const onStep = useCallback(async () => {
    switch (playControllerState) {
      case PlayControllerState.INITIALIZED:
        // TODO: 初回フレームの処理
        break;
      case PlayControllerState.STEP_RUNNING:
      case PlayControllerState.PAUSED:
        setPlayControllerState(PlayControllerState.STEP_RUNNING);
        break;
    }
  }, [playControllerState]);
  
  const onPlay = useCallback(async () => { 
    if (playControllerState == PlayControllerState.INITIALIZED) {
        // TODO: 初回フレームの処理
    }
    setPlayControllerState(PlayControllerState.RUNNING);
  }, [playControllerState]);
  
  const tick = = useCallback(async (): boolean => { 
if (playControllerState === PlayControllerState.PAUSED) {
      return false;
    }

    if (
      playControllerState === PlayControllerState.INITIALIZED ||
      playControllerState === PlayControllerState.RUNNING ||
      playControllerState === PlayControllerState.STEP_RUNNING
    ) {
        
        // TODO: フレームごとの処理

        if (
          playControllerState === PlayControllerState.RUNNING &&
          isSessionFinished()
        ) {
          setPlayControllerState(PlayControllerState.PAUSED);
        }
        return true;
     
    } else {
      return false;
    }
  }, []);

  return (
        <PlayControllerPanel
          state={playControllerState}
          speed={0.5}
          onReset={onReset}
          onPause={onPause}
          onStep={onStep}
          onPlay={onPlay}
          tick={tick}
        />
    );

コード

PlayControllerPanel.tsx
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, ButtonGroup, Slider, Typography } from '@mui/material';
import { Pause, PlayCircle, RestartAlt, SkipNext } from '@mui/icons-material';
import styled from '@emotion/styled';

export enum PlayControllerState {
  INITIALIZING,
  INITIALIZED,
  RUNNING,
  STEP_RUNNING,
  PAUSED,
}

export type PlayControllerProps = {
  state: PlayControllerState;
  speed: number;
  onPlay: () => void;
  onPause: () => void;
  onStep: () => void;
  onReset: () => void;
  tick: () => Promise<boolean>;
};
const StyledButtonGroup = styled(ButtonGroup)`
  margin: 4px;
  padding-left: 4px;
  padding-right: 4px;
  border-collapse: separate;
`;
const PlayButton = styled(Button)`
  border-radius: 30px;
  border-spacing: 1px;
  border-style: solid;
  border-color: lightgrey;
  border-width: 1px;
`;
const PaddedPlayButton = styled(PlayButton)`
  padding: 2px 8px 2px 4px;
`;
const MainPlayButton = styled(PlayButton)``;
export const PlayControllerPanel = (props: PlayControllerProps) => {
  const [speed, setSpeed] = useState<number>(props.speed);
  const [elapsed, setElapsed] = useState<number | null>(null);
  const [fps, setFps] = useState<string | null>(null);

  const stepFrameCount = useRef<number>(0);

  const frameCountRef = useRef<number>(0);
  const [frameCount, setFrameCount] = useState<number>(0);

  const handleRef = useRef<number | null>(null);
  const sessionStartedAt = useRef<number>(0);
  const timeTotal = useRef<number>(0);

  const requestStop = useRef<boolean>(false);

  const onSpeedChange = useCallback(
    (_: Event, value: number[] | number) => {
      setSpeed(value as number);
    },
    [],
  );

  const start = useCallback(() => {
    handleRef.current !== null && cancelAnimationFrame(handleRef.current);
    handleRef.current = null;
    requestStop.current = false;

    sessionStartedAt.current = Date.now();
    const delay = Math.pow(1.0 - speed, 2) * 1000 + 1;
    let loopStartedAt = Date.now();

    const loop = async () => {
      const delta = Date.now() - loopStartedAt;
      if (delta >= delay) {
        const result = await props.tick();
        loopStartedAt = Date.now();
        if (result) {
          frameCountRef.current++;
          setFrameCount(frameCountRef.current);
          updateFPS();
          handleRef.current = requestAnimationFrame(loop);
        } else {
          handleRef.current !== null && cancelAnimationFrame(handleRef.current);
          handleRef.current = null;
          requestStop.current = true;
          return;
        }
      } else {
        !requestStop.current &&
          (handleRef.current = requestAnimationFrame(loop));
      }
    };
    handleRef.current = requestAnimationFrame(loop);
  }, [speed, props.tick]);

  const pause = useCallback(() => {
    handleRef.current !== null && cancelAnimationFrame(handleRef.current);
    handleRef.current = null;
    requestStop.current = true;
    if (sessionStartedAt.current > 0) {
      timeTotal.current += Date.now() - sessionStartedAt.current;
      sessionStartedAt.current = 0;
    }
  }, []);

  const reset = useCallback(() => {
    sessionStartedAt.current = 0;
    frameCountRef.current = 0;
    stepFrameCount.current = 0;
    timeTotal.current = 0;
    setFrameCount(0);
    setElapsed(null);
    setFps(null);
  }, []);

  const doStep = useCallback(() => {
    frameCountRef.current++;
    setFrameCount(frameCountRef.current);
    stepFrameCount.current++;
    props.onStep();
  }, [props.onStep]);

  const step = useCallback(() => {
    props.tick();
  }, [props.tick]);

  const updateFPS = useCallback(() => {
    if (sessionStartedAt.current && sessionStartedAt.current > 0) {
      const elapsedMsec =
        Date.now() - sessionStartedAt.current + timeTotal.current;
      if (frameCountRef.current >= 10) {
        setFps(
          (
            (frameCountRef.current - stepFrameCount.current) /
            (elapsedMsec / 1000)
          ).toFixed(1),
        );
      }
      setElapsed(Math.round(elapsedMsec / 1000));
    }
  }, []);

  useEffect(() => {
    switch (props.state) {
      case PlayControllerState.INITIALIZING:
      case PlayControllerState.INITIALIZED:
        pause();
        reset();
        break;
      case PlayControllerState.RUNNING:
        start();
        break;
      case PlayControllerState.STEP_RUNNING:
        step();
        break;
      case PlayControllerState.PAUSED:
        pause();
        break;
      default:
        break;
    }
  }, [props.state, props.tick]);

  useEffect(() => {
    return () => {
      pause();
    };
  }, []);

  useEffect(() => {
    if (props.speed !== speed) {
      pause();
      start();
    }
  }, [speed, start]);

  const color =
    props.state === PlayControllerState.RUNNING ? 'warning' : 'primary';

  return (
    <>
      <StyledButtonGroup color={color}>
        <PaddedPlayButton
          title="Restart"
          variant="contained"
          size="small"
          onClick={props.onReset}
          disabled={
            props.state === PlayControllerState.INITIALIZING ||
            props.state === PlayControllerState.INITIALIZED ||
            props.state === PlayControllerState.RUNNING ||
            props.state === PlayControllerState.STEP_RUNNING
          }
        >
          <RestartAlt />
          Reset
        </PaddedPlayButton>
        <PaddedPlayButton
          title="Pause"
          variant="contained"
          size="small"
          onClick={props.onPause}
          disabled={
            props.state === PlayControllerState.INITIALIZING ||
            props.state === PlayControllerState.INITIALIZED ||
            props.state === PlayControllerState.STEP_RUNNING ||
            props.state === PlayControllerState.PAUSED
          }
        >
          <Pause />
          Pause
        </PaddedPlayButton>
        <PaddedPlayButton
          title="Step"
          variant="contained"
          size="small"
          onClick={doStep}
          disabled={
            props.state === PlayControllerState.INITIALIZING ||
            props.state === PlayControllerState.RUNNING ||
            props.state === PlayControllerState.STEP_RUNNING
          }
        >
          <SkipNext />
          Step
        </PaddedPlayButton>
        <MainPlayButton
          title="Play"
          variant="contained"
          onClick={props.onPlay}
          disabled={
            props.state === PlayControllerState.INITIALIZING ||
            props.state === PlayControllerState.RUNNING ||
            props.state === PlayControllerState.STEP_RUNNING
          }
        >
          <PlayCircle />
          Play
        </MainPlayButton>
      </StyledButtonGroup>

      <Box
        style={{
          display: 'flex',
          margin: '8px 8px 8px',
          columnGap: '8px',
          justifySelf: 'center',
          width: '100%',
        }}
      >
        <Box
          style={{
            display: 'flex',
            columnGap: '18px',
            padding: '3px 8px 3px 8px',
            borderRadius: '30px',
            borderStyle: 'solid',
            borderWidth: '1px',
            borderColor: '#b3b3b3',
            width: '100%',
          }}
        >
          <Typography style={{ alignSelf: 'center' }}>🐢</Typography>
          <Slider
            value={speed}
            aria-label="custom thumb label"
            valueLabelDisplay="auto"
            step={0.01}
            min={0}
            max={1}
            color={color}
            onChange={onSpeedChange}
          />
          <Typography style={{ alignSelf: 'center' }}>🐇</Typography>
        </Box>
        <Box
          style={{
            display: 'flex',
            columnGap: '12px',
            padding: '2px 8px 2px 8px',
            borderRadius: '30px',
            borderStyle: 'solid',
            borderWidth: '1px',
            borderColor: '#b3b3b3',
            width: '45%',
          }}
        >
          <Typography
            fontSize={11}
            color={color}
            style={{
              width: '100%',
              alignSelf: 'center',
              textAlign: 'end',
              justifyContent: 'center',
            }}
          >
            {frameCount}
            {elapsed !== null ? '  / ' + elapsed + ' sec' : ''}
            {fps !== null ? ' (' + fps + ' fps)' : ''}
          </Typography>
        </Box>
      </Box>
    </>
  );
};

NPMパッケージ

(のちほど、GitHubにこのコンポーネントについての独立したリポジトリをつくり、NPMパッケージとしても登録しておきます)

まとめ

この記事では、ブラウザ上で動作するアプリの何らかの動作内容について、開始・ステップ実行・一時停止・リセットを制御するしくみをReactコンポーネントとしてつくったものについて、紹介しました。

こういう内容のコンポーネントで、オープンソースなものが、どこかに公開されているだろうと思って、あちこち探しましたが、うまく見つけられませんでした。みんなそれぞれが、車輪の再発明をして、自分のコードに組み込んでいるのだろうと思います。

というわけで、今回、自分でも作ってみました。誰かの役に立つといいなと思い、ここに記事化して公開しておくことにします。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?