この文章は、 Akashic Engine Advent Calendar 2019 の 1 日目です。
TL; DR
せん断変形
せん断変形というのは、言葉で説明しにくいのですが、イメージ的には「長方形を平行四辺形に歪めるような変形」です。こういうやつ。
Akashic Engine のエンティティ (g.E
) は、移動 (translate), 回転 (rotate), 拡大 (scale) 操作を提供しています。それぞれ x
, y
に angle
, そして scaleX
, scaleY
プロパティがそれです。Akashic Engine はブラウザ上では canvas 要素に描画するのですが、その canvas の 2D 描画系の仕様 を眺めても移動・回転・拡大が基本っぽいので、これはまあ不自然ではないでしょう。しかし拡大と回転をどう組み合わせても、せん断変形にはなりません。
……したい。したいですねせん断変形。
ところで、2D (平面上) の移動・回転・拡大は、数学的には 3x3 行列で表現できるいわゆる アフィン変換 です。そしてせん断もアフィン変換の一種です。
いえ、まあ数学的な背景には明るくないのですが、とにかく移動と拡大と回転は一つの 3x3 行列で表せますし、せん断もそこに含められます。実際 Akashic Engine のエンティティも、 x
, y
, angle
などから行列 (変換行列) を求めている箇所があります。
ならば g.E
を拡張してその処理を上書きしてしまえばいいじゃないか。せん断変形も込みの変換行列を設定してやればいいじゃないか。ということでやりました。
SkewableE
g.E
をせん断変形できるように拡張したエンティティ SkewableE
です。
(TypeScript です。JavaScript の場合は…… akashic init -t typescript-minimal
で生成したゲームにこのコードを加えてビルド (npm run build
) して、できた JS ファイルを使うとかしてください)
export interface SkewableEParameterObject extends g.EParameterObject {
/**
* X 軸方向のせん断。単位は度。
* 省略した場合、 0 。
*/
skewX?: number;
/**
* Y 軸方向のせん断。単位は度。
* 省略した場合、 0 。
*/
skewY?: number;
}
export class SkewableE extends g.E {
/**
* X 軸方向のせん断。単位は度。
* この値を変更した場合、 `this.modfied()` を呼び出す必要がある。
* 省略した場合、 0 。
*/
skewX: number;
/**
* Y 軸方向のせん断。単位は度。
* この値を変更した場合、 `this.modfied()` を呼び出す必要がある。
* 省略した場合、 0 。
*/
skewY: number;
private _realAngle: number;
constructor(param: SkewableEParameterObject) {
super(param);
this.skewX = param.skewX || 0;
this.skewY = param.skewY || 0;
this._realAngle = 0;
}
modified(): void {
super.modified.apply(this, arguments);
// ↑ の modified() が立てることがある ContextLess フラグを、必要なら落とす。
// ContextLess は描画高速化のためのフラグで、変換行列が無視できる時に立つ (そして実際無視される) ので、
// ここで落とさないと _updateMatrix() が呼ばれない。
if (this.skewX !== 0 || this.skewY !== 0) {
this.state &= ~g.EntityStateFlags.ContextLess;
}
}
render(): void {
// 描画中に (ContextLess とは別に) 変換行列を無視するショートカットパスに入る可能性がある。
// ここで一旦 angle を上書きすることで、絶対にそのショートカットパスには入らないようにする。
this._realAngle = this.angle;
this.angle = 1;
super.render.apply(this, arguments); // 何事もなかったかのように元の描画処理を呼ぶ。
this.angle = this._realAngle; // 書き換えた値を復帰。
}
_updateMatrix(): void {
const { width, height, scaleX, scaleY, x, y, anchorX, anchorY, skewX, skewY } = this;
const angle = this._realAngle;
// _updateMatrix() が値を代入することになっている対象の行列
// (_matrix が _matrix ってメンバを持ってるのややつらい感じですが……)
const target = this._matrix._matrix;
// ここで求める変換行列Mは、せん断・拡大・回転・平行移動の順で変形を適用するものである。
// これは次の式で表せる:
// M = A^-1 T R S K A
// ただしここで A, K, S, R, T はそれぞれ以下を表す変換行列である:
// A: アンカーを原点に移す (平行移動する) 変換
// K: X 軸方向に skewX 度、Y 軸方向に skewY 度せん断する変換
// S: X 軸方向に scaleX 倍、Y 軸方向に scaleY 倍する変換
// R: angle 度だけ回転する変換
// T: x, y の値だけ平行移動する変換
// それらは次のように表せる:
// 1 0 -ax 1 kx 0 sx 0 0
// A = [ 0 1 -ay] K = [ ky 1 0] S = [ 0 sy 0]
// 0 0 1 0 0 1 0 0 1
//
// c -s 0 1 0 x
// R = [ s c 0] T = [ 0 1 y]
// 0 0 1 0 0 1
// ただしここで
// ax = anchorX * width, ay = anchorY * height, kx = tan(skewX), ky = tan(skewY)
// sx = scaleX, sy = scaleY, c = cos(angle * PI / 180), s = sin(angle * PI / 180)
// である。以下の実装は、これらから M の各要素を直接求めている。
const ax = (anchorX != null ? anchorX : 0.5) * width;
const ay = (anchorY != null ? anchorY : 0.5) * height;
const kx = Math.tan(skewX * Math.PI / 180);
const ky = Math.tan(skewY * Math.PI / 180);
const sx = scaleX;
const sy = scaleY;
const r = angle * Math.PI / 180;
const c = Math.cos(r);
const s = Math.sin(r);
const m0 = c * sx - s * ky * sy;
const m1 = s * sx + c * ky * sy;
const m2 = c * kx * sx - s * sy;
const m3 = s * kx * sx + c * sy;
target[0] = m0;
target[1] = m1;
target[2] = m2;
target[3] = m3;
target[4] = x - ax - ay * m2 - ax * m0;
target[5] = y - ay - ay * m3 - ax * m1;
}
}
はい。コメントから漏れ出る闇がいい感じにヤバイですね☆ 思ったより露骨に内部実装依存になってしまった……。
使い方
中身はさておき使い方は簡単で、 g.E
と一緒です。追加で skewX
, skewY
プロパティが使えるようになっていて、横方向・縦方向のせん断変形を指定します(単位は度)。
たとえば skewX
に 45 を指定すると、角が 45 度 (と 135 度) の平行四辺形に変形されます。 g.E
同様、それ自身は何も描画しないので、子要素に Sprite とかを append() して使うことになります。たとえばこういう感じの描画が、
こういうコードで作れます。
var scene = new g.Scene({
game: g.game,
assetIds: ["player"] // くらげの画像アセット
});
scene.loaded.add(function () {
var sprite = new g.Sprite({
scene: scene,
src: scene.assets["player"],
x: 0,
y: 0
});
var skewed = new SkewableE({
scene: scene,
skewX: 45, // 45 度横にせん断
x: 100,
y: 100,
});
skewed.append(sprite); // sprite をせん断して描画されるように
scene.append(skewed);
});
冒頭のアニメーションするくらげは、次のコードで生成されています。
scene.loaded.add(() => {
const sprite = new g.Sprite({
scene: scene,
src: scene.assets["player"],
anchorX: 0.5,
anchorY: 0.5,
});
const skewed = new SkewableE({
scene: scene,
anchorX: 0.5,
anchorY: 0.5,
x: g.game.width / 2,
y: g.game.height / 2,
});
skewed.update.add(() => {
skewed.skewX++; // 毎フレーム skew の角度を増やしていって 100 度でリセット。
if (skewed.skewX > 100)
skewed.skewX = 0;
skewed.modified();
});
skewed.append(sprite);
scene.append(skewed);
});
中身
modified()
Akashic Engine でコードを書くと呼び出しまくるあの modified() です。
コメントのとおり、エンジンが高速化のために立てるフラグ ContextLess
が邪魔 (せん断変形したくても変換行列計算が丸ごとスキップされるパスに入ってしまう) ので、 skewX
, skewY
がついてたらフラグをねじ伏せます。やや厳しい。
render()
エンティティが実際に描画される時にエンジンが呼び出すメソッドです。
これもコメントのとおり、変換行列計算を無視するショートカットパスがエンジンにあるので、絶対にショートカットパスに入らないように angle を上書きしています。実際に _updateMatrix()
で行列を求める時は、保存しておいた _realAngle
を使っています。かなり厳しい。
_updateMatrix()
本題のメソッドです。(本当はここだけ書けばいいつもりだった)
Akashic Engine はエンティティの変換行列を求める必要がある時、これを呼び出します。このメソッドは変換行列を求め、 this._matrix
を更新しなければなりません。
中身はなんやらよーわからん計算式が並んでいますが、これはコメントのとおりの行列の掛け算を一つ一つ計算した温かみのある式です。嘘です。Wolfram|Alpha さま がお解きあそばされました。筆算がめんどくさかったので。Wolfram|Alpha はいいぞ。
まとめ
せん断変形をするために、変換行列計算処理をかっぱらったエンティティ SkewableE
を作りました。
残念ながらめっちゃエンジンの内部処理依存になってしまったので、エンジンのバージョンが上がると動かない可能性があります。が、まあたまにはダーティな真似もいいんじゃないでしょうか。
g.E
の元の処理と比べると、skew が入った瞬間に変換行列の計算コストが数倍に膨らんでしまったので、この機能が直接 g.E
に入る公算はあまり大きくなさそうですが、行列を触るもうちょっとマシな手段はエンジンに欲しいなあという気がしました (感想文) 。