はじめに
アトリビュート番外編のトランスフォームフィードバック:
p5.jsで生のwebglを使いながらアトリビュートで遊ぶ(番外編)(トランスフォームフィードバックの初歩)
を復習したくなったので復習しようと思いました。というかインターリーブでなんか作りたいなと。ついでにテクスチャも学んだのでそれも加えてわちゃわちゃしようと思ったのです。題材は毎度おなじみのwgldさんのこれです:
GPGPU でパーティクルを大量に描く
以前p5のフレームバッファでこれの記事を書いた気がするんですが(よく覚えてない)、トランスフォームフィードバックではやってなかった気がするので、復習がてらやろうと思いました。というかフレームバッファは高級な話題なのでおいそれと手が付けられないのです(本当に難しいので)。単にバッファの中身を書き換えるだけのTFFの方がずっと簡単ですね...インターリーブ指定すれば一つのバッファに全部ぶち込んでくれるし。便利。そういうわけでこれをやろうと思いました。
TFFとかVAOなどについては説明が終わっているので、ざっくりとしか触れません。ご了承ください。
コード全文
p5便利なのでちょいちょい活躍してもらっています。
TFF GPGPU particle
let loopFunction = () => {};
function setup() {
const vaoPair = [];
const tfoPair = [];
const NUM_X = 512;
const NUM_Y = 512;
const NUM = NUM_X * NUM_Y;
createCanvas(windowWidth, windowHeight, WEBGL);
pixelDensity(1);
const gl = this._renderer.GL;
// まずはshaderを作ってしまう
// 更新用
const vsUpdate =
`#version 300 es
layout (location = 0) in vec2 aPosition;
layout (location = 1) in vec2 aVelocity;
out vec2 vUpdatedPosition;
out vec2 vUpdatedVelocity;
const float SPEED = 0.05; // 基本速度
uniform vec2 uMouse; // マウス位置
uniform bool uMouseIsPressed; // マウスダウンのフラグ
uniform float uSpeedFactor; // マウスを離していると減衰する
void main(){
vec2 p = aPosition;
vec2 v = aVelocity;
vec2 v1 = normalize(uMouse - p) * 0.2;
vec2 v2 = normalize(v1 + v);
vUpdatedPosition = p + v2 * SPEED * uSpeedFactor;
vUpdatedVelocity = (uMouseIsPressed ? v2 : v);
}
`;
const fsUpdate =
`#version 300 es
void main(){}
`;
// インターリーブなのでseparate:false.
const pgUpdate = createShaderProgram(gl, {
vs:vsUpdate, fs:fsUpdate,
outVaryings:["vUpdatedPosition", "vUpdatedVelocity"], separate:false
});
// 描画用
const vsDisplay =
`#version 300 es
layout (location = 0) in vec2 aPosition;
layout (location = 1) in vec2 aVelocity;
uniform vec2 uAdjustment;
void main(){
gl_Position = vec4(aPosition*uAdjustment, 0.0, 1.0);
gl_PointSize = 1.0;
}
`;
const fsDisplay =
`#version 300 es
precision highp float;
uniform vec3 uBaseColor;
out vec4 fragColor;
void main(){
fragColor = vec4(uBaseColor, 0.0);
}
`;
const pgDisplay = createShaderProgram(gl, {
vs:vsDisplay, fs:fsDisplay
});
// じゃあバッファ作るか。初期位置は-1~1
const data = new Float32Array(NUM*4);
for(let y=0; y<NUM_Y; y++){
for(let x=0; x<NUM_X; x++){
const px = 2.0*(x+0.5)/NUM_X-1.0;
const py = 2.0*(y+0.5)/NUM_Y-1.0;
const offset = y*NUM_X + x;
data[4*offset] = px;
data[4*offset+1] = py;
data[4*offset+2] = 0;
data[4*offset+3] = 0;
}
}
// 初期データ用
const buf0 = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf0);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_COPY);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
// 更新用の受け皿
const buf1 = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf1);
gl.bufferData(gl.ARRAY_BUFFER, NUM*16, gl.DYNAMIC_COPY);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
const bufs = [buf0, buf1]; // 混乱するのでバッファの入れ替えはしません。
// VAOとTFO作りますね
for(let i=0; i<2; i++){
// 先にVAO作りますね。インターリーブ~
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, bufs[i]);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 16, 0); // position
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 16, 8); // velocity
gl.enableVertexAttribArray(0);
gl.enableVertexAttribArray(1);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindVertexArray(null);
vaoPair.push(vao);
// 次にTFO作りますね
const tfo = gl.createTransformFeedback();
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, tfo);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, bufs[i]);
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);
tfoPair.push(tfo);
}
// じゃあもういいですね。あとは...
let speedFactor = 0;
colorMode(HSB,1); // 色用。p5に仕事してもらおう。
// じゃあちゃちゃっと作っちゃうね
const gr = createGraphics(width,height);
gr.textSize(19).textAlign(CENTER,CENTER).textStyle(ITALIC);
gr.noStroke().fill(255); gr.text("tff particle", width/2, height/2);
const tx = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tx);
// そうそう、grの場合はeltを使うのと...
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0,
gl.RGBA, gl.UNSIGNED_BYTE, gr.elt
);
// フィルターがポイントですね。
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
// おしまい。じゃあ表示用のshader作るね。
const vsLogo =
`#version 300 es
layout (location = 0) in vec2 aPosition;
out vec2 vUv;
void main(){
vUv = 0.5 + 0.5*aPosition;
vUv.y = 1.0 - vUv.y;
gl_Position = vec4(aPosition, -1.0, 1.0);
}
`;
const fsLogo =
`#version 300 es
precision highp float;
in vec2 vUv;
out vec4 fragColor;
uniform sampler2D uLogo;
void main(){
fragColor = texture(uLogo, vUv);
}
`;
const pgLogo = createShaderProgram(gl, {vs:vsLogo, fs:fsLogo});
// 先に入れちゃおうね。更新不要なんでね。
gl.useProgram(pgLogo);
setUniformValue(gl, pgLogo, "1i", "uLogo", 0);
gl.useProgram(null);
// これはVAO要らんわね。デフォルト設定でいいわね。
const bufLogo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufLogo);
gl.bufferData(gl.ARRAY_BUFFER, new Int8Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.BYTE, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
loopFunction = (time) => {
// まずvaoの0とtfoの1で更新作業やるね。
gl.bindVertexArray(vaoPair[0]);
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, tfoPair[1]);
gl.useProgram(pgUpdate);
const mx = 2.0*mouseX/width-1.0;
const my = 1.0-2.0*mouseY/height;
const xFactor = (width < height ? 1 : width/height);
const yFactor = (width > height ? 1 : height/width);
setUniformValue(gl, pgUpdate, "2f", "uMouse", mx*xFactor, my*yFactor);
setUniformValue(gl, pgUpdate, "1i", "uMouseIsPressed", mouseIsPressed);
if(mouseIsPressed){
speedFactor = 1.0;
}else{
speedFactor *= 0.95;
}
setUniformValue(gl, pgUpdate, "1f", "uSpeedFactor", speedFactor);
gl.beginTransformFeedback(gl.POINTS);
gl.enable(gl.RASTERIZER_DISCARD);
gl.drawArrays(gl.POINTS, 0, NUM);
gl.disable(gl.RASTERIZER_DISCARD);
gl.endTransformFeedback();
gl.bindVertexArray(null);
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);
// 次にvaoの1で描画やるね。
gl.clearColor(0,0,0,1);
gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
gl.bindVertexArray(vaoPair[1]);
gl.useProgram(pgDisplay);
const col = color(fract(time*0.1), 0.8, 1);
setUniformValue(
gl, pgDisplay, "3f", "uBaseColor",
red(col)/255, green(col)/255, blue(col)/255
);
setUniformValue(gl, pgDisplay, "2f", "uAdjustment", 1/xFactor, 1/yFactor);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.drawArrays(gl.POINTS, 0, NUM);
gl.bindVertexArray(null);
// 仕方ないですね
gl.useProgram(pgLogo);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.disable(gl.BLEND); // ブレンドはここで切るかな。
gl.flush();
// Pairをswapするね
vaoPair.reverse();
tfoPair.reverse();
// お疲れ様でした。え?ロゴが欲しい...そうっすね...
}
}
function draw() {
loopFunction(millis()/1000);
}
// -------------------- common webgl util -------------------- //
function createShaderProgram(gl, params = {}){
const {vs, fs, layout = {}, outVaryings = [], separate = true} = params;
const vsShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vsShader, vs);
gl.compileShader(vsShader);
if(!gl.getShaderParameter(vsShader, gl.COMPILE_STATUS)){
console.log("vertex shaderの作成に失敗しました");
console.error(gl.getShaderInfoLog(vsShader));
return null;
}
const fsShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fsShader, fs);
gl.compileShader(fsShader);
if(!gl.getShaderParameter(fsShader, gl.COMPILE_STATUS)){
console.log("fragment shaderの作成に失敗しました");
console.error(gl.getShaderInfoLog(fsShader));
return null;
}
const program = gl.createProgram();
gl.attachShader(program, vsShader);
gl.attachShader(program, fsShader);
// レイアウト指定はアタッチしてからリンクするまでにやらないと機能しない。
// なおこの機能はwebgl1でも使うことができる。webgl2で実装されたというのは誤解。
setAttributeLayout(gl, program, layout);
setOutVaryings(gl, program, outVaryings, separate);
gl.linkProgram(program);
if(!gl.getProgramParameter(program, gl.LINK_STATUS)){
console.log("programのlinkに失敗しました");
console.error(gl.getProgramInfoLog(program));
return null;
}
// uniform情報を作成時に登録してしまおう
program.uniforms = getActiveUniforms(gl, program);
// attribute情報も登録してしまおう。
program.attributes = getActiveAttributes(gl, program);
return program;
}
// レイアウトの指定。各attributeを配列のどれで使うか決める。
// 指定しない場合はデフォルト値が使われる。基本的には通しで0,1,2,...と付く。
function setAttributeLayout(gl, pg, layout = {}){
const names = Object.keys(layout);
if(names.length === 0) return;
for(const name of names){
const index = layout[name];
gl.bindAttribLocation(pg, index, name);
}
}
// TFF用の設定箇所
function setOutVaryings(gl, pg, outVaryings = [], separate = true){
if(outVaryings.length === 0) return;
gl.transformFeedbackVaryings(pg, outVaryings, (separate ? gl.SEPARATE_ATTRIBS : gl.INTERLEAVED_ATTRIBS));
}
function getActiveUniforms(gl, pg){
const uniforms = {};
// active uniformの個数を取得。
const numActiveUniforms = gl.getProgramParameter(pg, gl.ACTIVE_UNIFORMS);
console.log(`active uniformの個数は${numActiveUniforms}個です`);
for(let i=0; i<numActiveUniforms; i++){
const uniform = gl.getActiveUniform(pg, i);
const location = gl.getUniformLocation(pg, uniform.name);
uniform.location = location;
uniforms[uniform.name] = uniform;
}
return uniforms;
}
function getActiveAttributes(gl, pg){
const attributes = {};
// active attributeの個数を取得。
const numActiveAttributes = gl.getProgramParameter(pg, gl.ACTIVE_ATTRIBUTES);
console.log(`active attributeの個数は${numActiveAttributes}個です`);
for(let i=0; i<numActiveAttributes; i++){
// 取得は難しくない。uniformと似てる。
const attribute = gl.getActiveAttrib(pg, i);
const location = gl.getAttribLocation(pg, attribute.name);
console.log(`${attribute.name}のlocationは${location}です`);
attribute.location = location;
attributes[attribute.name] = attribute;
}
return attributes;
}
function setUniformValue(gl, pg, type, name){
const {uniforms} = pg;
// 存在しない場合はスルー
if(uniforms[name] === undefined) return;
// 存在するならlocationを取得
const location = uniforms[name].location;
// nameのあとに引数を並べる。そのまま放り込む。
const args = [...arguments].slice(4);
switch(type){
case "1f": gl.uniform1f(location, ...args); break;
case "2f": gl.uniform2f(location, ...args); break;
case "3f": gl.uniform3f(location, ...args); break;
case "4f": gl.uniform4f(location, ...args); break;
case "1fv": gl.uniform1fv(location, ...args); break;
case "2fv": gl.uniform2fv(location, ...args); break;
case "3fv": gl.uniform3fv(location, ...args); break;
case "4fv": gl.uniform4fv(location, ...args); break;
case "1i": gl.uniform1i(location, ...args); break;
case "2i": gl.uniform2i(location, ...args); break;
case "3i": gl.uniform3i(location, ...args); break;
case "4i": gl.uniform4i(location, ...args); break;
case "1iv": gl.uniform1iv(location, ...args); break;
case "2iv": gl.uniform2iv(location, ...args); break;
case "3iv": gl.uniform3iv(location, ...args); break;
case "4iv": gl.uniform4iv(location, ...args); break;
}
if(type === "matrix2fv"||type==="matrix3fv"||type==="matrix4fv"){
const v = (args[0] instanceof Float32Array ? args[0] : new Float32Array(args[0]));
switch(type){
case "matrix2fv": gl.uniformMatrix2fv(location, false, v); break;
case "matrix3fv": gl.uniformMatrix3fv(location, false, v); break;
case "matrix4fv": gl.uniformMatrix4fv(location, false, v); break;
}
}
}
後半1/3くらいはいつものwebGLのユーティリティですね。省略しても良かったんですが復習用に残してあります。
実行結果:
更新用シェーダー
更新の仕方は本家に準拠しています。今回はインターリーブで、pとvが分かれています。3Dとかだとvec4にまとめられないので、ここで練習しておこうと思いました。
#version 300 es
layout (location = 0) in vec2 aPosition;
layout (location = 1) in vec2 aVelocity;
out vec2 vUpdatedPosition;
out vec2 vUpdatedVelocity;
const float SPEED = 0.05; // 基本速度
uniform vec2 uMouse; // マウス位置
uniform bool uMouseIsPressed; // マウスダウンのフラグ
uniform float uSpeedFactor; // マウスを離していると減衰する
void main(){
vec2 p = aPosition;
vec2 v = aVelocity;
vec2 v1 = normalize(uMouse - p) * 0.2;
vec2 v2 = normalize(v1 + v);
vUpdatedPosition = p + v2 * SPEED * uSpeedFactor;
vUpdatedVelocity = (uMouseIsPressed ? v2 : v);
}
速度の更新はマウスが押されている間だけ、マウスを離すとファクターが減衰するなど、そっくりそのまま書き写していますね。なおフラグメントシェーダがあれなのはTFFでノンラスタライズだからです。TFFはラスタライズも可能なんですが、ドローコールがあれしかないのでほぼ実用に供さないですね。今回はインターリーブなのでseparateをfalseにしています。
// インターリーブなのでseparate:false.
const pgUpdate = createShaderProgram(gl, {
vs:vsUpdate, fs:fsUpdate,
outVaryings:["vUpdatedPosition", "vUpdatedVelocity"], separate:false
});
描画用シェーダー
位置座標を正規化デバイス座標に渡しているだけで難しいことはしてないので割愛します。adjustmentは縦横のサイズが違う場合でも正方形状にするための調整用です。
バッファの用意
片方に初期データを入れます。もう片方は受け皿です。初期データは-1~1で正方形状にします。速度は0でいいですね。インターリーブなのでそれぞれ交互で詰めています。最初のタイミングで更新後のデータをぶち込んで、あとは取り扱いに差がなくなります。あっちに入れたり、こっちに入れたり、ぎっこんばったんです。なおバッファ自体を入れ替えると混乱するのでやりません。入れ替えるのはレイアウトです。
なお、受け皿にデータを与える必要は無いので、領域だけ確保しています。
// 初期データ用
const buf0 = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf0);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_COPY);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
// 更新用の受け皿
const buf1 = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf1);
gl.bufferData(gl.ARRAY_BUFFER, NUM*16, gl.DYNAMIC_COPY);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
VAOとTFOの用意
VAOはvertexAttributeArrayの0番にバッファを置いて用途を決めるものです。TFOはbufferBaseの0番にバッファを置いて用途を決めるものです。それぞれ0と1に対して1つずつ用意します。たとえばVAOの0とTFOの1を起動させれば0から1に行くわけです。逆なら逆。分かりやすいですね。
const bufs = [buf0, buf1]; // 混乱するのでバッファの入れ替えはしません。
// VAOとTFO作りますね
for(let i=0; i<2; i++){
// 先にVAO作りますね。インターリーブ~
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, bufs[i]);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 16, 0); // position
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 16, 8); // velocity
gl.enableVertexAttribArray(0);
gl.enableVertexAttribArray(1);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindVertexArray(null);
vaoPair.push(vao);
// 次にTFO作りますね
const tfo = gl.createTransformFeedback();
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, tfo);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, bufs[i]);
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);
tfoPair.push(tfo);
}
インターリーブなのでストライドに16、オフセットは0と8が指定されています。
ロゴテクスチャ
もう準備は終わったんですが、文字を置きたいということで、まあそれくらいならいいかなということで用意することにしました。せっかくテクスチャを習ったので。中央に「tff particle」の文字が置かれる様にしようかと。ここでp5が活躍します。2Dキャンバスにお手軽に描画できるので便利です。それを使ってテクスチャを用意しています。ポイントはeltで要素にアクセスできることと、フィルター設定を忘れないことですね。なお表示はアトリビュートでやっています。無くてもいいんですがgl_VertexIDによる描画がめんどくさいので。さっき用意したVAOのおかげでデフォルトのVAAはいじり放題なので、VAOは使いません。
ちなみにソースのサイズ指定をサボっていますが、これはスマホでも動いてほしいがためにpixelDensity(1)を実行しているからです。なのでダイレクトにwidthとheightを指定しています。ほんとはちゃんとeltを取ったうえでwidthとheightを使って定めるのがマナーです。
これで準備完了ですね。
メインループ
じゃあさっそく描画しますね。
更新作業
VAOの0番とTFOの1番で0から1への書き写し作業をします。バインドとかラスタライズの無効化については説明してあるので割愛します。begin~endはTFFをバインドしてから実行しないと適用されないので順番に注意してください。ここでのメインはむしろユニフォームの設定ですね。
gl.useProgram(pgUpdate);
const mx = 2.0*mouseX/width-1.0;
const my = 1.0-2.0*mouseY/height;
const xFactor = (width < height ? 1 : width/height);
const yFactor = (width > height ? 1 : height/width);
setUniformValue(gl, pgUpdate, "2f", "uMouse", mx*xFactor, my*yFactor);
setUniformValue(gl, pgUpdate, "1i", "uMouseIsPressed", mouseIsPressed);
if(mouseIsPressed){
speedFactor = 1.0;
}else{
speedFactor *= 0.95;
}
setUniformValue(gl, pgUpdate, "1f", "uSpeedFactor", speedFactor);
マウスが押されていないと速度係数が減衰するとか、画面のサイズに応じて送るべきマウスの値を微調整、とかやってます。説明は以上です。
メイン描画作業
で、次にVAOの1番で描画します。要は書き込まれた方ですね。このあとVAOとTFOそれぞれの長さ2の配列をswapして、今度は1から0に焼いて0で描画することになります。以下交互にぎっこんばったんです。
ここで重要なのはまずきちんとクリアすることですね。
gl.clearColor(0,0,0,1);
gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
忘れました(ごめんなさい)。で、色についてはp5のcolorModeを使っています。便利なので。あとブレンドは通常のアルファブレンドをしているんですが、シェーダーで透明度を0にすることで加算にしています。なんかかっこいいと思ったからです(雑)。
gl.useProgram(pgDisplay);
const col = color(fract(time*0.1), 0.8, 1);
setUniformValue(
gl, pgDisplay, "3f", "uBaseColor",
red(col)/255, green(col)/255, blue(col)/255
);
setUniformValue(gl, pgDisplay, "2f", "uAdjustment", 1/xFactor, 1/yFactor);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.drawArrays(gl.POINTS, 0, NUM);
ここでブレンドを切ってないのはこの後の処理でも使うからです。
ロゴ描画作業
ここで切っても良かったんですが、さっき用意したテクスチャを貼り付けたかったので後回しにしました。なおシェーダーの方で深度値が0になるよう、$z$を-1にしています。
void main(){
vUv = 0.5 + 0.5*aPosition;
vUv.y = 1.0 - vUv.y;
gl_Position = vec4(aPosition, -1.0, 1.0);
}
まあ手前に置きたいので。それでブレンドは継続で、この後で切っています。なおVAOはもう解除済みなのでデフォルトの板描画用のVAAが使われます。まああんまお行儀良くないんですが別にいいでしょう。
gl.useProgram(pgLogo);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.disable(gl.BLEND); // ブレンドはここで切るかな。
gl.flush();
// Pairをswapするね
vaoPair.reverse();
tfoPair.reverse();
最後にペアをリバースして終了ですね。
おわりに
トランスフォーム・フィードバック。
たとえば3Dで、バーテックスシェーダにおいて、位置とか法線の書き換えをしてあれこれしたいと思っても、その結果はVBOの書き換えとして保存(フィードバック)されるわけでは無いので、毎回初期状態からスタートしないといけないわけですが、TFFを使えばそのトランスフォームをVBOにフィードバックできるわけですね。多分そういう意味なんだと思います。そういう動かし方ばかりではないんでしょうが、選択肢が多いのは良い事です。
復習おわり!
ここまでお読みいただいてありがとうございました。