株式会社グローバルゲート公式ブログ

2Dグラフィックスライブラリ【Pixi.js】で高負荷アニメーションを作る

(こんな格好で仕事をしているわけではありません)

こんにちは、株式会社グローバルゲートのモーリーです。 
 
 
先日に行ってきました! 

あまり下調べもせずイベントやパビリオンの予約もせずに行きましたが、それでも個性的なパビリオンや賑やかな会場を歩いているだけでその非日常感にワクワクさせられました。

15km前後は歩くことになると思いますので、これから行かれる方は靴や水分など体調にはくれぐれもご注意ください。 
(会場全体はUSJの3倍近くあるそうです)

さて、万博のことは一旦置いておいて、今記事では2DグラフィックスライブラリであるPixiJSをご紹介したいと思います。 

PixiJSとは? 

PixiJSとはWebブラウザ上で高速かつ高品質な2Dグラフィックスを描画するためのJavaScriptライブラリです。主にHTML5の<canvas>要素やWebGLを活用して、ゲームやアニメーション、インタラクティブな演出をスムーズに表現することができます。 
 
ブラウザでアニメーションや図形描画を行うにはcanvasやSVGをJavaScriptで操作する必要があります。しかし、これらは複雑なアニメーションや大量のオブジェクトを行おうとすると、コードが煩雑になったりパフォーマンスに限界が生じたりします。CSSによるアニメーションはできることに限界があります。 
PixiJSはその煩雑さをシンプルなコードで実装し、更にWebGLによる表示によって高いパフォーマンスを発揮します。 
 
PixiJSを使うことで、低レベル(ブラウザの根本に近い機能)のWebGL APIを抽象化し、より簡単かつ効率的に2D描画を実現することができます。たとえば、スプライト(画像を表示するオブジェクト)、テキスト、シェイプ(図形)、フィルター効果、マスク処理など、ゲームや視覚演出でよく使われる機能がすべて備わっています。 
 
PixiJSは純粋な2Dエンジンであるため3D処理は行いませんが、その分2D表現に特化しており、非常に軽量かつ高速です。そのため、カジュアルゲームから広告バナー、アニメーション付きUI、ビジュアライゼーションまで、さまざまなプロジェクトで活用されています。 
 
実際にPixiJSは、企業のキャンペーンサイトやスマホゲームのブラウザ版、デジタルサイネージなどでも使われており、プロの現場でも多く使われているライブラリのひとつです。 

PixiJSの基本

それでは、実際にPixiJSを使ってみましょう。 
PixiJSによる描画は

1.ステージとなるcanvasの配置 
2.ステージ上にオブジェクトを配置 
3.オブジェクトにアニメーションやフィルター処理を追加 

という手順で行います。

0.ライブラリのロード 

まずはライブラリのロードを行います。 
今回はモダンブラウザで使えるimport構文を使ってみました。

<script type="module"> 
 import * as PIXI from 'https://cdn.jsdelivr.net/npm/pixi.js@8.10.0/dist/pixi.min.mjs'; 
</script>

1.初期化とcanvasの配置 

はじめにPIXIの初期化と土台となるcanvasの設置を行います。 

async function main() { 
    //PIXIの初期化 
    const app = new PIXI.Application(); 
    await app.init({ 
        resizeTo: window, 
        backgroundColor: 0xffffff, 
        antialias: true, 
    }); 
    //appendChildでcanvasを設置 
    document.body.appendChild(app.view); 

main();

DEMO

画面上には何も表示されていないように見えますが、canvasがHTML上に設置されています。 

2.オブジェクトを配置する 

canvas上にオブジェクトを設置します。 
今回は簡単に円を配置してみます。 

//円を配置 
const circle = new PIXI.Graphics(); 
circle.beginFill(0xff0000);           // 塗りつぶし色(16進数) 
circle.drawCircle(0, 0, 50);           // (x, y, 半径) 
circle.endFill(); 
// 画面中央に配置 
circle.x = app.screen.width / 2; 
circle.y = app.screen.height / 2; 
 
//stageへのappendChildでオブジェクトを配置 
app.stage.addChild(circle); 

DEMO

画面中央に赤い円が表示されました。

3.オブジェクトをアニメーションさせる 

次にこの円を動かしてみます。 

// 移動速度 
let speed = 3; 
// 毎フレーム実行 
app.ticker.add(() => { 
circle.x += speed; 
 
// 画面端で反転 
if (circle.x + radius >= app.screen.width || circle.x - radius <= 0) { 
    speed *= -1; 

}); 
 
// ウィンドウリサイズ時にも対応 
window.addEventListener('resize', () => { 
if (circle.x + radius > app.screen.width) { 
    circle.x = app.screen.width - radius; 

if (circle.x - radius < 0) { 
    circle.x = radius; 

}); 

DEMO

これで丸が動くアニメーションが作成できました。 

これだけならCSSとHTMLだけでもできそうですが、更に複雑なアニメーションに挑戦したいと思います。 

オブジェクトの数を増やす 

この丸を10000個配置してみましょう。 
HTML/CSS/Javascriptならコマ落ちやブラウザのフリーズもありそうですが、果たしてどうでしょうか。 

//PixiJS v8.10.0をCDNからインポート 
import * as PIXI from 'https://cdn.jsdelivr.net/npm/pixi.js@8.10.0/dist/pixi.min.mjs'; 
 
async function main() { 
    //PIXIの初期化 
    const app = new PIXI.Application(); 
    await app.init({ 
        resizeTo: window, 
        backgroundColor: 0x111111, 
        antialias: true, 
    }); 
    //appendChildでcanvasを設置 
    document.body.appendChild(app.view); 
 
 
    //1万個チャレンジ 
    const NUM_CIRCLES = 10000; 
    const RADIUS = 4; 
 
    // 円の配列と速度ベクトルの配列 
    const circles = []; 
    const velocities = []; 
 
    for (let i = 0; i < NUM_CIRCLES; i++) { 
        // ランダム色 
        const color = Math.floor(Math.random() * 0xffffff); 
 
        const g = new PIXI.Graphics(); 
        g.beginFill(color); 
        g.drawCircle(0, 0, RADIUS); 
        g.endFill(); 
 
        // ランダム初期位置 
        g.x = Math.random() * app.screen.width; 
        g.y = Math.random() * app.screen.height; 
 
        app.stage.addChild(g); 
        circles.push(g); 
 
        // ランダム速度ベクトル (-1〜1) 
        const angle = Math.random() * Math.PI * 2; 
        const speed = 1 + Math.random() * 1.5; // 少し速度にバラつき 
        velocities.push({ 
            dx: Math.cos(angle) * speed, 
            dy: Math.sin(angle) * speed, 
        }); 
    } 
 
    // アニメーション処理 
    app.ticker.add(() => { 
        for (let i = 0; i < NUM_CIRCLES; i++) { 
            const g = circles[i]; 
            const v = velocities[i]; 
 
            g.x += v.dx; 
            g.y += v.dy; 
 
            // 画面端で反射 
            if (g.x - RADIUS <= 0 || g.x + RADIUS >= app.screen.width) { 
                v.dx *= -1; 
            } 
            if (g.y - RADIUS <= 0 || g.y + RADIUS >= app.screen.height) { 
                v.dy *= -1; 
            } 
        } 
    }); 
 
 

main();

DEMO

1万個の円が表示されました!

私の環境ではスムーズに動いているように見えますが、皆様の環境ではどうでしょうか? 

フィルターを使ってみる:メタボールにする 

丸が接触するときにメタボールのように融合と分離をする処理を足してみましょう。 
これはCSSだけでは表現が難しい動きです。 
 
 
メタボールのフィルタ処理部分がかなり難しかったのでChatGPTに頼りました。 

 
        import * as PIXI from 'https://cdn.jsdelivr.net/npm/pixi.js@8.10.0/dist/pixi.min.mjs'; 
 
        async function main() { 
            const app = new PIXI.Application(); 
            await app.init({ 
                resizeTo: window, 
                backgroundColor: 0xeeeeee, 
                antialias: true, 
            }); 
            document.body.appendChild(app.view); 
 
            const BALL_COUNT = 200; 
 
            const makeBalls = () => { 
                const balls = []; 
                for (let i = 0; i < BALL_COUNT; i++) { 
                    balls.push({ 
                        x: Math.random() * app.screen.width, 
                        y: Math.random() * app.screen.height, 
                        vx: (Math.random() - 0.5) * 4, 
                        vy: (Math.random() - 0.5) * 4, 
                        radius: 1 + Math.random() * 20, 
                    }); 
                } 
                return balls; 
            }; 
 
            const ballsRed = makeBalls(); 
            const ballsBlue = makeBalls(); 
 
            const fragShader = (colorVec) => `precision mediump float; 
                uniform vec2 u_resolution; 
                uniform float u_threshold; 
                uniform vec3 u_balls[${BALL_COUNT}]; 
 
                void main() { 
                    vec2 uv = gl_FragCoord.xy; 
                    float field = 0.0; 
                    for (int i = 0; i < ${BALL_COUNT}; i++) { 
                        vec3 b = u_balls[i]; 
                        float dx = uv.x - b.x; 
                        float dy = uv.y - b.y; 
                        float d2 = dx*dx + dy*dy + 0.0001; 
                        field += (b.z * b.z) / d2; 
                    } 
                    if (field > u_threshold) { 
                        gl_FragColor = vec4${colorVec}; 
                    } else { 
                        discard; 
                    } 
                } 
            `; 
 
            const vertex = `in vec2 aPosition; 
                out vec2 vTextureCoord; 
 
                uniform vec4 uInputSize; 
                uniform vec4 uOutputFrame; 
                uniform vec4 uOutputTexture; 
 
                vec4 filterVertexPosition(void) { 
                    vec2 position = aPosition * uOutputFrame.zw + uOutputFrame.xy; 
                    position.x = position.x * (2.0 / uOutputTexture.x) - 1.0; 
                    position.y = position.y * (2.0 * uOutputTexture.z / uOutputTexture.y) - uOutputTexture.z; 
                    return vec4(position, 0.0, 1.0); 
                } 
 
                vec2 filterTextureCoord(void) { 
                    return aPosition * (uOutputFrame.zw * uInputSize.zw); 
                } 
 
                void main(void) { 
                    gl_Position = filterVertexPosition(); 
                    vTextureCoord = filterTextureCoord(); 
                } 
            `; 
 
            // フィルター作成関数(色をパラメータに) 
            const createMetaFilter = (colorVec, name) => { 
                return PIXI.Filter.from({ 
                    gl: { 
                        vertex: vertex, 
                        fragment: fragShader(colorVec), 
                    }, 
                    resources: { 
                        [name]: { 
                            u_resolution: {value: [app.screen.width, app.screen.height], type: 'vec2<f32>'}, 
                            u_threshold: {value: 1.0, type: 'f32'}, 
                            u_balls: {value: new Float32Array(BALL_COUNT * 3), type: 'vec3<f32>', size: BALL_COUNT}, 
                        } 
                    } 
                }); 
            }; 
 
            const filterBlue = createMetaFilter('(0.0, 0.3, 0.8, 1.0)', 'blueUniforms'); 
            const filterRed = createMetaFilter('(1.0, 0.0, 0.0, 1.0)', 'redUniforms'); 
 
            const quadBlue = new PIXI.Sprite(PIXI.Texture.WHITE); 
            quadBlue.width = app.screen.width; 
            quadBlue.height = app.screen.height; 
            quadBlue.filters = [filterBlue]; 
            app.stage.addChild(quadBlue); // 背景レイヤー 
 
            const quadRed = new PIXI.Sprite(PIXI.Texture.WHITE); 
            quadRed.width = app.screen.width; 
            quadRed.height = app.screen.height; 
            quadRed.filters = [filterRed]; 
            app.stage.addChild(quadRed); // 前景レイヤー 
 
            app.ticker.add((ticker) => { 
                // 位置更新(赤) 
                for (const b of ballsRed) { 
                    b.x += b.vx * ticker.deltaTime; 
                    b.y += b.vy * ticker.deltaTime; 
                    if (b.x < b.radius || b.x > app.screen.width - b.radius) b.vx *= -1; 
                    if (b.y < b.radius || b.y > app.screen.height - b.radius) b.vy *= -1; 
                } 
 
                // 位置更新(青) 
                for (const b of ballsBlue) { 
                    b.x += b.vx * ticker.deltaTime; 
                    b.y += b.vy * ticker.deltaTime; 
                    if (b.x < b.radius || b.x > app.screen.width - b.radius) b.vx *= -1; 
                    if (b.y < b.radius || b.y > app.screen.height - b.radius) b.vy *= -1; 
                } 
 
                // uniform更新(赤) 
                const uRed = filterRed.resources.redUniforms.uniforms; 
                for (let i = 0; i < BALL_COUNT; i++) { 
                    uRed.u_balls[i * 3 + 0] = ballsRed[i].x; 
                    uRed.u_balls[i * 3 + 1] = ballsRed[i].y; 
                    uRed.u_balls[i * 3 + 2] = ballsRed[i].radius; 
                } 
 
                // uniform更新(青) 
                const uBlue = filterBlue.resources.blueUniforms.uniforms; 
                for (let i = 0; i < BALL_COUNT; i++) { 
                    uBlue.u_balls[i * 3 + 0] = ballsBlue[i].x; 
                    uBlue.u_balls[i * 3 + 1] = ballsBlue[i].y; 
                    uBlue.u_balls[i * 3 + 2] = ballsBlue[i].radius; 
                } 
            }); 
 
            // リサイズ対応 
            window.addEventListener('resize', () => { 
                quadBlue.width = app.screen.width; 
                quadBlue.height = app.screen.height; 
                filterBlue.resources.blueUniforms.uniforms.u_resolution = [app.screen.width, app.screen.height]; 
 
                quadRed.width = app.screen.width; 
                quadRed.height = app.screen.height; 
                filterRed.resources.redUniforms.uniforms.u_resolution = [app.screen.width, app.screen.height]; 
            }); 
        } 
 
        main(); 

DEMO

無事冒頭の伏線(万博行ってきましたレポ)を回収できました! 

AIをプログラムで活用しようとすると、従来のプログラミング能力というよりやりたいことを言語化する能力が必要だと感じました(たとえば球体が変形しながら合成される形状を「メタボール」や「ブロブ」と呼ぶことを知っていないとAIに指示を出せない、など)

まとめ

ということで、今回はPixiJSの紹介と簡単な描画やアニメーションの作成を試してみました。 
特にWebGLによる滑らかなアニメーションが実装できる点は非常に心強く、CSSとJavascriptでは負荷が高すぎて諦めていた表現にも挑戦できそうです。 
当社によるWeb制作でも新たな表現方法として活用していきたいと思います。 

【関連記事】

ご相談・お問い合わせ

当社サービスについてのお問い合わせは下記までご連絡下さい。

お電話でのお問い合わせ

06-6121-7581 / 03-6415-8161