p5.jsで二次ベジエっぽい滑らかな動きを実現する

# はじめに
　滑らか、かつランダムな動きを実現する方法はたくさんあります。条件としては、

- まっすぐは嫌なのでちょいちょい曲がってほしい
- 常に止まらず進み続ける
- 端っこに着いたら反射するか反対側にループする
- 曲がると言っても同じところをぐるぐるではなく右に曲がったり左に曲がったりしてほしい
- しかし頻度が多すぎるとカクカクになるのでいただけない

そんなところです。たとえばノイズ関数を使って、方向変数に毎フレーム足し続けるというやり方があって、要するに[こないだの長方形のやつ](https://qiita.com/inaba_darkfox/items/ac99a02ea9b1d4e53ef3)ですが、若干カクカクになるんですよね。そこで今回は、もうちょっと見栄えのいい動かし方を提案しようと思います。

## コード全文
　[quadratic bezierlike move](https://openprocessing.org/sketch/2573639)
```js
let loopF = () => {};

function setup() {
  createCanvas(400, 400);

  const movers = [];
  for(let i=0; i<40; i++){
    const velocity = 2+random(4);
    const direction = random(TAU);
    movers.push(new Mover(10+random(380), 10+random(380), velocity * cos(direction), velocity * sin(direction)));
  }
  
  loopF = () => {
    background(0,60);
    noStroke();
    fill(255);

    movers.forEach((m) => m.update());
    movers.forEach((m) => m.display());
  }
}

function draw() {
  loopF();
}

class Mover{
  constructor(x, y, u, v){
    this.position = createVector(x, y);
    this.velocity = createVector(u, v);
    this.acceleration = 0;
    this.duration = 0;
    this.properFrameCount = 0;
    this.updateForce();
  }
  updateForce(){
    this.properFrameCount = 0;
    this.duration = 20 + Math.floor(60*Math.random());
    this.acceleration = (Math.random()<0.5 ? 1 : -1) * (0.03 + Math.random()*0.07);
  }
  adjustment(){
    if(this.position.x < 0) this.position.x += width;
    if(this.position.x > width) this.position.x -= width;
    if(this.position.y < 0) this.position.y += height;
    if(this.position.y > height) this.position.y -= height;
    if(this.velocity.mag() > 8){ this.velocity.mult(0.85); }
  }
  update(){
    this.velocity.add(this.velocity.copy().rotate(Math.PI/2).normalize().mult(this.acceleration * Math.sin(Math.PI*this.properFrameCount/this.duration)));
    this.position.add(this.velocity);
    this.adjustment();
    this.properFrameCount++;
    if(this.properFrameCount === this.duration){
      this.updateForce();
    }
  }
  display(){
    circle(this.position.x, this.position.y, 4);
  }
}
```
実行結果：

![wff32ffffffffff.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/437349/0fc6bb8a-1510-4626-9c91-721d8ece8973.png)

びゅんびゅん～

## 工夫したポイント
　コンストラクタ
```js
    this.position = createVector(x, y);
    this.velocity = createVector(u, v);
    this.acceleration = 0;
    this.duration = 0;
    this.properFrameCount = 0;
    this.updateForce();
```
　位置と速度は事前にランダム決定します。加速度はスカラーですが、足すときに一工夫します。正の値も、負の値も取ります。durationとproperFrameCountは加速度を決めるスパンを決定します。updateForceで初期設定をします。
　次にupdateのところです。
```js
  update(){
    this.velocity.add(this.velocity.copy().rotate(Math.PI/2).normalize().mult(this.acceleration * Math.sin(Math.PI*this.properFrameCount/this.duration)));
    this.position.add(this.velocity);
    this.adjustment();
    this.properFrameCount++;
    if(this.properFrameCount === this.duration){
      this.updateForce();
    }
  }
```
速度の更新は、速度に対して垂直な方向に力を掛けることで実行しています。速度に垂直なベクトルを作り、単位ベクトルにし、加速度を掛けて加えます。その都度速度に垂直な方向が変わるので毎フレーム計算しています。これで右や左に曲がることとなります。さらにその大きさに進捗の比率と正弦関数を使って始めと終わりで弱くなる係数を掛けています。これで滑らかにつながるわけですね。

　工夫はこれだけですが、それなりに滑らかに動いているようです。

　ついでに、端っこでループさせています。反射でも割と綺麗です。今回はループにしました。
```js
  adjustment(){
    if(this.position.x < 0) this.position.x += width;
    if(this.position.x > width) this.position.x -= width;
    if(this.position.y < 0) this.position.y += height;
    if(this.position.y > height) this.position.y -= height;
    if(this.velocity.mag() > 8){ this.velocity.mult(0.85); }
  }
```
それと、まあ速度に常に垂直方向の力を掛けているんでおそらく要らないんですが、あまり速度が大きくなり過ぎないように減衰トラップを仕掛けています。これも単純に減らすと汚くなるので、乗算で滑らかに減らしています。

# おわりに
　いろんなやり方があると思います。たとえばFALさんのmicrobeなんかは面白いです。
　[Rectangle Microbes](https://openprocessing.org/sketch/541797)
速度にも始めと終わりで小さくなる仕掛けをするとか、いろいろ想像できそうで楽しいですね。ここまでお読みいただいてありがとうございました。

## 応用
　トランスフォームを行列で表現することで、向きの変化をローカル回転で表現できるようになります。進行についても$x$軸正方向に向かうとしてローカルトランスフォームで実現できるので分かりやすくなります。さらに長方形のところでやった反対側にも描画するというテクニックを組み合わせればループさせることができます。もろもろ詰め込んだものがこちらになります。

　[rects byunbyun](https://openprocessing.org/sketch/2573836)
```js
let loopF = () => {};

const {BooleanArray} = foxUtils;
const {MT3, Vecta} = fox3Dtools;

const corners = [];

function setup() {
  createCanvas(windowWidth, windowHeight);
  const NUM = Math.floor(width*height/10000);

  const movers = [];
  for(let i=0; i<NUM; i++){
    movers.push(new Mover(
      10+Math.random()*(width-20), 10+Math.random()*(height-20),
      2+Math.random()*4, Math.random()*Math.PI*2, 40, 20
    ));
  }
  rectMode(CENTER);
  noStroke();

  const a = width/2;
  const b = height/2;
  const shifts = [
    -1,-1, 1,-1, 3,-1, -1,1, 1,1, 3,1, -1,3, 1,3, 3,3
  ];
  for(let i=0; i<16; i+=2){
    corners.push(
      new Mover(shifts[i]*a, shifts[i+1]*b, 0, 0, width, height)
    );
  }

  loopF = () => {
    background(0, 60);
    fill(255);

    movers.forEach((m) => m.update());
    movers.forEach((m) => {
      m.display();
    });
  }
}

function draw() {
  loopF();
}

class Mover{
  constructor(x, y, v, d, w, h){
    this.m = new MT3(
      Math.cos(d), -Math.sin(d), x, Math.sin(d), Math.cos(d), y, 0, 0, 1
    );
    this.prevM = this.m.copy();
    this.velocity = v;
    this.acceleration = 0;
    this.duration = 0;
    this.properFrameCount = 0;
    this.velocityFactor = 0.5;
    this.updateForce();

    this.w = w;
    this.h = h;
  }
  getVertices(){
    const result = [];
    const shifts = [-1,-1,1,-1,1,1,-1,1];
    for(let i=0; i<8; i+=2){
      result.push(new Vecta(this.w*shifts[i]/2, this.h*shifts[i+1]/2, 1));
    }
    return result;
  }
  getCenter(){
    return new Vecta(this.m.m[2], this.m.m[5], 1);
  }
  updateForce(){
    this.properFrameCount = 0;
    this.duration = 40 + Math.floor(40*Math.random());
    this.acceleration = (Math.random()<0.5 ? 1 : -1) * (0.02 + Math.random()*0.08);
    this.velocityFactor = 0.5 * Math.random();
  }
  adjustment(){
    const adjust = new Vecta();
    const centerPosition = this.getCenter();
    if(centerPosition.x < 0){ adjust.set(width, 0, 0); }
    else if(centerPosition.x > width){ adjust.set(-width, 0, 0); }
    else if(centerPosition.y < 0){ adjust.set(0, height, 0); }
    else if(centerPosition.y > height){ adjust.set(0, -height, 0); }

    this.m.globalTranslation(adjust.x, adjust.y);
    this.prevM.globalTranslation(adjust.x, adjust.y);
  }
  update(){
    const progressFactor = Math.sin(Math.PI*this.properFrameCount/this.duration);
    this.prevM.set(this.m);
    this.m.localTranslation(this.velocity * (this.velocityFactor + (1.0 - this.velocityFactor)*progressFactor), 0);
    this.m.localRotation(this.acceleration * progressFactor);

    this.adjustment();
    this.properFrameCount++;
    if(this.properFrameCount === this.duration){
      this.updateForce();
    }
    this.w = 40 + 10 * Math.cos(millis()*TAU*this.velocity/2000);
    this.h = 500/this.w;
  }
  display(){
    const allMats = [this.m];
    for(const c of corners){
      if(Mover.collide(this, c)){
        const cc = c.getCenter();
        allMats.push(this.m.copy().globalTranslation(width/2 - cc.x, height/2 - cc.y));
      }
    }
    allMats.forEach((m) => {
      applyMatrix(...m.convert());
      rect(0, 0, this.w, this.h);
      resetMatrix();
    });
  }
  static collide(r0, r1){
    const m10 = r0.m.multM(r1.m.invert(true), true);
    const global1 = new BooleanArray(r1.getVertices().map((v) => m10.multV(v)));
    if(global1.all((v) => v.x > r0.w/2)) return false;
    if(global1.all((v) => v.x < -r0.w/2)) return false;
    if(global1.all((v) => v.y > r0.h/2)) return false;
    if(global1.all((v) => v.y < -r0.h/2)) return false;

    const m01 = m10.invert(true);
    const global0 = new BooleanArray(r0.getVertices().map((v) => m01.multV(v)));
    if(global0.all((v) => v.x > r1.w/2)) return false;
    if(global0.all((v) => v.x < -r1.w/2)) return false;
    if(global0.all((v) => v.y > r1.h/2)) return false;
    if(global0.all((v) => v.y < -r1.h/2)) return false;

    return true;
  }
}
```
　工夫したのはupdateのところですね。速度にファクターをかますことで、動き方を工夫してあります。

<iframe width="560" height="315" src="https://www.youtube.com/embed/dpCqyQ7w1rs?si=oS7cv_K3rpnOHbbL" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

面白いですね～。