RYDEENの曲に合う動画をelectron+three.jsで作っている。
動画化はどのように行っているかというと、canvasでの描画結果を複数のビットマップファイルに落として、ffmpegでオーディオ・ファイルとマージしてmp4形式にしている。 肝の部分は描画結果をビットマップファイルに落とすところで、今までは以下の方法で行っていた。
- canvasにthree.jsで1フレーム描画
- canvasのtoDataURL()でpng画像データ化(data URL形式)
- 先頭から「,」までを取り除き、BASE64文字列化
- new Buffer((文字列),'base64')オブジェクトでバイナリデータ化
- Bufferオブジェクトの中身をファイル名.pngとして保存
- 必要なフレーム分 1-5を繰り返し
コードにすると以下のような感じ。
var data = d3.select('#console').node().toDataURL('image/png');
data = data.substr(data.indexOf(',') + 1);
var buffer = new Buffer(data, 'base64');
writeFile('./temp/out' + ('000000' + frameNo.toString(10)).slice(-6) + '.png',buffer,'binary'));
しばらくの間これで問題はなかったが、1080pの解像度で複雑な描画を行うようになると処理時間がすごくかかるようになってしまった。エンコードとファイル保存に時間がかかってしまっているらしい。
なのでコードを改良することにした。調べた結果jpeg形式にするとファイルサイズが小さくなるので保存が速くなるらしい。あと気になるのはバイナリデータであるcanvasビットマップをわざわざData URL化し、さらにそれをバイナリデータ化するという冗長な処理である。なんかうまい方法ないだろうかと調べたら、three.jsのreadRenderTargetPixels()
を使えば何とかなりそうだ。
私はthree.jsのexampleで使われているEffectComposerという、ポストプロセスをするためのヘルパークラスを使っている。
このEffectComposerはWebGLRenderTarget
を介して、ポストプロセスを行うためのものである。さらに複数のポストプロセスをチェインする機能も持っている。
なのでポストプロセスで受け渡されるWebGLRenderTarget
をreadRenderTargetPixels()
で読みだせば、ピクセルデータがバイナリで得られる。なのでポストプロセスの1つとしてキャプチャーパスを作ることにした。
class SFCapturePass extends THREE.Pass {
constructor(width = 0, height = 0) {
super();
this.buffers = [];
for (let i = 0; i < 4; ++i) {
this.buffers.push(new Uint8Array(width * height * 4));
}
this.bufferIndex = 0;
this.currentIndex = 0;
this.width = width;
this.height = height;
this.uniforms = THREE.UniformsUtils.clone(THREE.CopyShader.uniforms);
this.material = new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader: THREE.CopyShader.vertexShader,
fragmentShader: THREE.CopyShader.fragmentShader
});
this.camera = new THREE.OrthographicCamera(- 1, 1, 1, - 1, 0, 1);
this.scene = new THREE.Scene();
this.quad = new THREE.Mesh(new THREE.PlaneBufferGeometry(2, 2), null);
this.scene.add(this.quad);
}
render(renderer, writeBuffer, readBuffer, delta, maskActive) {
this.currentIndex = this.bufferIndex;
renderer.readRenderTargetPixels(readBuffer, 0, 0, this.width, this.height, this.buffers[this.bufferIndex]);
this.bufferIndex = (this.bufferIndex + 1) & 3;
this.uniforms["tDiffuse"].value = readBuffer.texture;
this.quad.material = this.material;
if (this.renderToScreen) {
renderer.render(this.scene, this.camera);
} else {
renderer.render(this.scene, this.camera, writeBuffer, this.clear);
}
}
}
THREE.SFCapturePass = SFCapturePass;
これをEffectComposer
に追加し、render()
するとbuffers
プロパティに描画ビットマップデータの過去4画面分が格納される。
なぜ過去4画面なのかというと1画面分だけだとファイル保存中に書き込まれる可能性があるような気がして、念のためバッファを4画面用意することにした。
意味はないかもしれない。
さらにピクセルデータをjpeg方式で保存するためにsharpというネイティブ・モジュールを使用することにした。が、これをelectronで使用するには下の方法でインストール後ビルドしなおす必要があった。
保存するコードは以下のような感じ。
var sharp = require('sharp');
function saveImage(buffer,path,width,height)
{
return new Promise((resolve,reject)=>{
sharp(buffer,{raw:{width:width,height:height,channels:4}})
.rotate(180)
.jpeg()
.toFile(path,(err)=>{
if(err) reject(err);
resolve();
});
});
}
.
.
saveImage(new Buffer(sfCapturePass.buffers[sfCapturePass.currentIndex].buffer),'./temp/out' + ('000000' + frameNo.toString(10)).slice(-6) + '.jpeg',WIDTH,HEIGHT);
レポジトリ:
https://github.com/sfpgmr/rydeen/tree/a1779d82212fb6f2c8d730329ad4f043de915e20
結果だが処理速度はかなり速くなり、我慢できる程度にはなった。
が動画をYouTubeにアップすると、画質がものすごく荒れる。再エンコードされるのはしょうがないんだけど、ローカルで観ている画質とはかなり違ってしまっている。推奨されるパラメータでエンコードして、ビットレートも8M bps以下に落としてはいるんだけどね。実写とかだと、背景はそこそこ固定されているし、動く部分が少ないのでそんなに荒れないんだけどね。やっぱり画面全体が激しく変化するようなものは難しいのだろうか。ちょっとフレームレートを落としてみようかなとも思っている。でも落としすぎるとカクカクしちゃうしなあ。。