これは何?
-
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弱くらいまで性能を引き出すことができました。
機能
- このコンポーネントの状態は、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}
/>
);
コード
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コンポーネントとしてつくったものについて、紹介しました。
こういう内容のコンポーネントで、オープンソースなものが、どこかに公開されているだろうと思って、あちこち探しましたが、うまく見つけられませんでした。みんなそれぞれが、車輪の再発明をして、自分のコードに組み込んでいるのだろうと思います。
というわけで、今回、自分でも作ってみました。誰かの役に立つといいなと思い、ここに記事化して公開しておくことにします。