はじめに
画面の端っこに着いた長方形が反対側にも描画されると嬉しいという話です。
端っこに着いたら反対側へ送る場合のロジックは簡単にできるんですが、端っこに半分だけ重なっている場合、もう半分が反対側に表示されないと不自然な見た目になってしまうので、それを改善しようかと思います。
いろんなやり方があって、要するに長方形の重なり判定をすればいいんですが、今回は傾いた長方形に限ることとし、行列で判定しようかと思います。
コード全文
let loopF = () => {};
const {RoundRobinArray} = foxUtils;
const {MT3, Vecta} = fox3Dtools;
function setup() {
createCanvas(400, 400);
const rects = [];
for(let i=0; i<10; i++){
rects.push(new MyRect(random(width), random(height), 40, 20, random(TAU)));
}
const aW = width/2;
const aH = height/2;
const coeffs = [
-1,-1, 1,-1, 3,-1, -1,1, 3,1, -1,3, 1,3, 3,3
];
const corners = [];
for(let i=0; i<16; i+=2){
corners.push(new MyRect(aW*coeffs[i], aH*coeffs[i+1], aW*2, aH*2));
}
const colors = new RoundRobinArray(
color("red"), color("blue"), color("green"), color("darkorange"), color("magenta"),
color("khaki"), color("teal"), color("navy"), color("gold"), color("silver")
);
loopF = () => {
background(0);
noStroke();
fill(255);
for(const r of rects){
fill(colors.pick());
r.update();
r.display();
for(const c of corners){
if(collideWithRect(r, c)){
push();
translate(c.w*0.5 - c.x, c.h*0.5 - c.y);
r.display();
pop();
}
}
}
}
}
function draw() {
loopF();
}
function collideWithRect(r0, r1){
// 行列を使う
const m = new MT3();
m.setRotation(-r0.d).localTranslation(r1.x-r0.x, r1.y-r0.y).localRotation(r1.d);
const vs1 = r1.getVertices();
for(const v of vs1){
const w = m.multV(v);
if(Math.abs(w.x) < r0.w/2 && Math.abs(w.y) < r0.h/2) return true;
}
m.setRotation(-r1.d).localTranslation(r0.x-r1.x, r0.y-r1.y).localRotation(r0.d);
const vs0 = r0.getVertices();
for(const v of vs0){
const w = m.multV(v);
if(Math.abs(w.x) < r1.w/2 && Math.abs(w.y) < r1.h/2) return true;
}
}
class MyRect{
constructor(x,y,w,h,d = 0,areaW = width,areaH = height){
this.x = x;
this.y = y;
this.w = w;
this.h = h;
this.d = d;
this.areaW = areaW;
this.areaH = areaH;
this.index = MyRect.index++;
}
getVertices(){
const result = [];
for(let i=-1; i<2; i+=2){
for(let k=-1; k<2; k+=2){
result.push(new Vecta(i*this.w/2, k*this.h/2, 1));
}
}
return result;
}
update(){
this.x += cos(this.d);
this.y += sin(this.d);
this.d += 0.03 * (noise(millis()/400, this.index) > 0.5 ? 1 : -1);
if(this.x < 0) this.x += this.areaW;
if(this.x > this.areaW) this.x -= this.areaW;
if(this.y < 0) this.y += this.areaH;
if(this.y > this.areaH) this.y -= this.areaH;
this.w = 40 + 10 * cos(millis()/200 + this.index);
this.h = 800/this.w;
}
display(){
push();
translate(this.x,this.y);
rotate(this.d);
rect(-this.w/2, -this.h/2, this.w, this.h);
pop();
}
}
MyRect.index = 0;
MyRectクラス
$x,y$は中心位置です。$w,h$は横幅と縦幅です。$d$は方向角度です。この方向に沿って描画します。
更新処理は次のようになっています。
update(){
this.x += cos(this.d);
this.y += sin(this.d);
this.d += 0.03 * (noise(millis()/400, this.index) > 0.5 ? 1 : -1);
if(this.x < 0) this.x += this.areaW;
if(this.x > this.areaW) this.x -= this.areaW;
if(this.y < 0) this.y += this.areaH;
if(this.y > this.areaH) this.y -= this.areaH;
this.w = 40 + 10 * cos(millis()/200 + this.index);
this.h = 800/this.w;
}
$x,y$に方向分の速度を足して、方向についてはノイズでいじっています。シリアルナンバーをクラス変数を使って用意してあり、それで違いを出しています。ついでに幅も変更しています。伸び縮みするのかわいい。
MyRect同士の衝突判定
3次の正方行列のクラスを使用します。
function collideWithRect(r0, r1){
// 行列を使う
const m = new MT3();
m.setRotation(-r0.d).localTranslation(r1.x-r0.x, r1.y-r0.y).localRotation(r1.d);
const vs1 = r1.getVertices();
for(const v of vs1){
const w = m.multV(v);
if(Math.abs(w.x) < r0.w/2 && Math.abs(w.y) < r0.h/2) return true;
}
m.setRotation(-r1.d).localTranslation(r0.x-r1.x, r0.y-r1.y).localRotation(r0.d);
const vs0 = r0.getVertices();
for(const v of vs0){
const w = m.multV(v);
if(Math.abs(w.x) < r1.w/2 && Math.abs(w.y) < r1.h/2) return true;
}
}
getVerticesは自身の中心に対する相対的な角の座標です。これを自身の傾きだけ回転し、さらに中心の分だけ平行移動してグローバル座標を出して、そこから相手の長方形の中心が原点になるようにまた平行移動し、最後に相手の長方形の分だけ逆に傾けると、相手の長方形が座標軸に平行となるような座標系でこっちの長方形の角を表示できます。それを行列の掛け算で計算します。
これを交代で2回やることにより重なり判定をします。
端っこに着いたらループさせる
画面の上下左右、及びはす向かいも含めて合計8つの長方形を用意します。
const aW = width/2;
const aH = height/2;
const coeffs = [
-1,-1, 1,-1, 3,-1, -1,1, 3,1, -1,3, 1,3, 3,3
];
const corners = [];
for(let i=0; i<16; i+=2){
corners.push(new MyRect(aW*coeffs[i], aH*coeffs[i+1], aW*2, aH*2));
}
そして描画の際に重なり判定をし、重なっていたら反対側に描画します。
for(const c of corners){
if(collideWithRect(r, c)){
push();
translate(c.w*0.5 - c.x, c.h*0.5 - c.y);
r.display();
pop();
}
}
これでループします。
ちょっとした工夫
それぞれ違う色を付けるのに、色の配列を使うんですが、ちょっと工夫してます。RoundRobinArrayと言って、いわゆるArrayWrapperの一種ですが、中身を取り出すたびに内部インデックスが進んで、最後まで行くと最初に戻ります。それに10種類の色をぶち込んで順繰りに取り出すことで個別に色を付けています。
const colors = new RoundRobinArray(
color("red"), color("blue"), color("green"), color("darkorange"), color("magenta"),
color("khaki"), color("teal"), color("navy"), color("gold"), color("silver")
);
こういうしょうもない道具を作るのも楽しいものです。
行列を使う理由
ベクトルの演算でもできます。というかp5だけで完結するのでライブラリは要らないです。例:
//m.setRotation(-r0.d).localTranslation(r1.x-r0.x, r1.y-r0.y).localRotation(r1.d);
const vs1 = r1.getVertices();
for(const v of vs1){
//const w = m.multV(v);
v.rotate(r1.d).add(r1.x-r0.x, r1.y-r0.y).rotate(-r0.d);
if(Math.abs(v.x) < r0.w/2 && Math.abs(v.y) < r0.h/2) return true;
}
それでも行列を使ったのは、計算量を考えた時に、回転や平行移動を4つ分やる手間が省けるからです。あと単純に行列が好きだからですね。習熟すると使いたくなるものです。
おわりに
一般の単純閉曲線同士の場合は角度を使ったりとか、いろんな方法があると思います。ここで取り扱ったのは一例です。
ここまでお読みいただいてありがとうございました。
補足
実はここで紹介した方法では矩形同士の衝突、というか重なりを正確には計算できません。次のような場合に失敗します。
/* 前略 */
function setup() {
createCanvas(400, 400);
const rects = [];
for(let i=0; i<10; i++){
rects.push(new MyRect(random(width), random(height), 40, 20, random(TAU)));
}
const rect0 = new MyRect(200, 200, 400, 100, 0);
const rect1 = new MyRect(200, 200, 100, 400, 0);
console.log(collideWithRect(rect0, rect1));
noStroke();
background(0);
fill(255);
rect0.display();
rect1.display();
}
/* 以下略 */
頂点が入らなくても交叉します。正確に割り出すには、凸図形に絞ったうえで、いずれかの辺に対して、すべての頂点がそれと反対側にある必要があります。そういう辺が一つでもあればOKです。これを交代でやればいいです。反対側の定義には凸図形の辺の回る向きが重要になってきます。それを考慮したうえで構成すると次のようになります。
const {Vecta} = fox3Dtools;
let loopF = () => {};
function setup() {
createCanvas(400, 400);
const q0 = [
Vecta.create(100,100,0), Vecta.create(300,50,0),
Vecta.create(200,290,0),Vecta.create(100,360,0)
];
const q1 = [
Vecta.create(-80,-10,0), Vecta.create(-80,10,0),
Vecta.create(80,10,0), Vecta.create(80, -10, 0)
];
noStroke();
loopF = () => {
background(0);
fill(255);
beginShape();
q0.forEach((v) => vertex(...v.array()))
endShape();
const q2 = [];
for(let k=0; k<q1.length; k++){
q2.push(q1[k].rotate(0,0,1,millis()*TAU/2000, true).add(mouseX,mouseY,0));
}
if(quadCollide(q0, q2)){ fill("red"); }else{ fill("blue"); }
beginShape();
q2.forEach((v) => vertex(...v.array()))
endShape();
}
}
function draw(){
loopF();
}
// 凸図形の場合の交叉判定
function quadCollideHalf(q0, q1){
const q0Edges = [];
for(let i=0; i<q0.length; i++){
q0Edges.push(q0[(i+1)%q0.length].sub(q0[i],true));
}
let angleSum0 = 0;
for(let i=0; i<q0.length; i++){
angleSum0 += q0Edges[i].angleTo(q0Edges[(i+1)%q0.length]);
}
const qlockwise0 = (angleSum0 > Math.PI ? 1 : -1);
// q0[i]とq0Edges[i]についてq1[k]をすべてチェックする
for(let i=0; i<q0.length; i++){
let flag0 = false;
for(let k=0; k<q1.length; k++){
// いずれかでもこっち側にあったらtrueにしてcontinue
if(q0Edges[i].angleTo(q1[k].sub(q0[i],true)) * qlockwise0 > 0) flag0 = true;
}
// いずれかの辺で反対側に全部あるよ!→交わらない
if(!flag0) return false;
}
return true;
}
function quadCollide(q0, q1){
return quadCollideHalf(q0, q1) && quadCollideHalf(q1, q0);
}
angleToとはベクトルをベクトルに重ねる際の向きを考慮した角度です。デフォルトでは0,0,1で調べることになっています。明示的な「p5のangleBetween」です(負になる可能性が明示的という意味)。q0のいずれかの辺に対し、q1のすべての点が反対側にあれば非衝突が確定します。これを逆でもやります。外積の符号の解釈に際しては図形の向きが関係してくるので、先にそれを調べています。
このように、正確に判定出来ました。なおこの記事で紹介している長方形に限ったやり方をこれに準じたもので書き換えるのは簡単です。絶対値でちょいちょいっとするだけで出来ます。面倒なので書き換えませんが...。
書き換えました。
rect loopMove bug_fixed
メソッドはこんな感じですね
function collideWithRect(r0, r1){
// 行列を使う
const m = new MT3();
const geqX = (v,n) => v.x > n;
const geqY = (v,n) => v.y > n;
const leqX = (v,n) => v.x < n;
const leqY = (v,n) => v.y < n;
m.setRotation(-r0.d).localTranslation(r1.x-r0.x, r1.y-r0.y).localRotation(r1.d);
const vs1 = r1.getVertices();
const ba1 = new BooleanArray(vs1.map((v) => m.multV(v)));
if(ba1.all(geqX, r0.w/2)) return false;
if(ba1.all(leqX, -r0.w/2)) return false;
if(ba1.all(geqY, r0.h/2)) return false;
if(ba1.all(leqY, -r0.h/2)) return false;
m.setRotation(-r1.d).localTranslation(r0.x-r1.x, r0.y-r1.y).localRotation(r0.d);
const vs0 = r0.getVertices();
const ba0 = new BooleanArray(vs0.map((v) => m.multV(v)));
if(ba0.all(geqX, r1.w/2)) return false;
if(ba0.all(leqX, -r1.w/2)) return false;
if(ba0.all(geqY, r1.h/2)) return false;
if(ba0.all(leqY, -r1.h/2)) return false;
return true;
}
BooleanArrayとは、ライブラリ内のArrayWrapperを使って自前で用意したものです。
class BooleanArray extends ArrayWrapper{
constructor(){
super(...arguments);
}
all(func = () => true){
const args = [...arguments];
args.shift(0);
for(const a of this){
if(!func(a, ...args)) return false;
}
return true;
}
any(func = () => true){
const args = [...arguments];
args.shift(0);
for(const a of this){
if(func(a, ...args)) return true;
}
return false;
}
}
mebiusboxさんの記事で知ったんですが、glslにはallとanyという便利な関数があるそうです。そこでそれを適用できる配列クラスを作りました。allとanyを使うと関数をすべての引数に適用した場合のandやorを取れます。それでやりました。引数も指定できる柔軟な仕様になっています。いずれライブラリに含めようかな...
以上です。
簡易的な方法
長方形の直径、すなわち対角線の長さを取得して固定し、これと中心から確実に長方形が含まれる正方形を作ります。これが交わるかどうかで判定することができます。あくまで必要条件のみですが、反対側に描画すべきかどうかを知るだけならこれで充分な場合もあります。