О чем этот пример

Создание игр часто требует выхода за рамки 2D. Интеграция мощного 3D-движка Three.js с Phaser открывает новые возможности: от сложных визуальных эффектов и анимаций до полноценных гибридных сцен. Этот подход позволяет использовать проверенную простоту Phaser для игровой логики и интерфейса, одновременно задействуя богатый инструментарий Three.js для трёхмерной графики, не покидая контекст одного приложения. Пример демонстрирует ключевой механизм — `Extern` Game Object. Этот объект позволяет передать управление рендерингом внешней библиотеке, в данном случае Three.js, и синхронно отображать 2D-спрайты Phaser поверх 3D-сцены. Это практичный способ добавить 3D-элементы в меню, карты или специальные эффекты, не переписывая проект с нуля.

Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.

Живой запуск

Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.

Исходный код


class Example extends Phaser.Scene
{
    constructor ()
    {
        super();
    }

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.script('threejs', 'js/three145.js');
        this.load.image('logo', 'assets/sprites/phaser3-logo-small.png');
        this.load.image('bg', 'assets/skies/gradient3.png');
    }

    create ()
    {
        this.add.image(400, 300, 'bg');

        this.init3D();

        this.add.image(0, 0, 'logo').setOrigin(0, 0);
    }

    //  This is all mostly ThreeJS code
    init3D ()
    {
        const camera = new THREE.PerspectiveCamera(70, 800 / 600, 1, 10000);

        camera.position.z = 300;

        const scene = new THREE.Scene();

        const texture1 = new THREE.TextureLoader().load('assets/normal-maps/brick.jpg');
        const texture2 = new THREE.TextureLoader().load('assets/textures/gold.png');
        const texture3 = new THREE.TextureLoader().load('assets/textures/grass.png');

        const material1 = new THREE.MeshBasicMaterial({ map: texture1 });
        const material2 = new THREE.MeshBasicMaterial({ map: texture2 });
        const material3 = new THREE.MeshBasicMaterial({ map: texture3 });

        const geometry1 = new THREE.BoxGeometry(100, 100, 100);
        const mesh1 = new THREE.Mesh(geometry1, material1);

        const geometry2 = new THREE.SphereGeometry(64, 32, 16);
        const mesh2 = new THREE.Mesh(geometry2, material2);

        const geometry3 = new THREE.CylinderGeometry(35, 35, 80, 32);
        const mesh3 = new THREE.Mesh(geometry3, material3);

        mesh2.position.x = -200;
        mesh3.position.x = 200;

        scene.add(mesh1);
        scene.add(mesh2);
        scene.add(mesh3);

        //  Tell three to use the Phaser canvas
        //  Also: Notice we're using the WebGL1 Renderer here
        const renderer = new THREE.WebGL1Renderer({
            canvas: this.sys.game.canvas,
            context: this.sys.game.context,
            antialias: true,
        });

        //  Create the Phaser Extern, tells Phaser to hand-off rendering to ThreeJS
        const view = this.add.extern();

        renderer.setPixelRatio(1);
        renderer.setSize(800, 600);

        //  You can skip this if threeJS is providing the _background_
        //  and all Phaser objects are on-top of it
        renderer.autoClear = false;

        //  The Extern render function
        view.render = (webGLRenderer, drawingContext, calcMatrix) => {

            //  This is essential to get ThreeJS to reset the GL state
            renderer.resetState();

            // Ensure the DrawingContext framebuffer is bound.
            webGLRenderer.glWrapper.updateBindingsFramebuffer({
                bindings: {
                    framebuffer: drawingContext.framebuffer
                }
            }, true);

            mesh1.rotation.x += 0.005;
            mesh1.rotation.y += 0.01;

            mesh2.rotation.x -= 0.005;
            mesh2.rotation.y += 0.02;

            mesh3.rotation.x += 0.03;
            mesh3.rotation.y += 0.02;

            renderer.render(scene, camera);

            //  Call it again, after rendering, if you get graphical corruption
            // renderer.resetState();

        };
    }
}

const config = {
    type: Phaser.WEBGL,
    width: 800,
    height: 600,
    backgroundColor: '#000000',
    parent: 'phaser-example',
    scene: Example
};

const game = new Phaser.Game(config);

Подготовка сцены и загрузка ресурсов

Как и в любом проекте Phaser, работа начинается с подготовки сцены. В методе preload мы загружаем не только стандартные изображения для фона и логотипа, но и скрипт самой библиотеки Three.js.

preload ()
{
    this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
    this.load.script('threejs', 'js/three145.js');
    this.load.image('logo', 'assets/sprites/phaser3-logo-small.png');
    this.load.image('bg', 'assets/skies/gradient3.png');
}

Метод load.script добавляет скрипт Three.js в DOM, делая глобальный объект THREE доступным для использования в нашем коде. После загрузки в create мы добавляем фоновое изображение стандартным для Phaser способом через this.add.image и инициируем создание 3D-сцены.

create ()
{
    this.add.image(400, 300, 'bg'); // 2D фон от Phaser
    this.init3D(); // Инициализация 3D мира
    this.add.image(0, 0, 'logo').setOrigin(0, 0); // 2D спрайт Phaser поверх всего
}

Обратите внимание на порядок: сначала фон, затем 3D-сцена, и только потом — логотип. Это гарантирует, что 2D-логотип будет отрисован поверх 3D-объектов.

Создание 3D-мира с помощью Three.js

Метод init3D содержит чистый код инициализации Three.js. Здесь создаётся камера, сцена, геометрия, материалы и меши (объекты).

const camera = new THREE.PerspectiveCamera(70, 800 / 600, 1, 10000);
camera.position.z = 300;
const scene = new THREE.Scene();

Загружаются текстуры и создаются материалы типа MeshBasicMaterial. Затем определяются три примитивные геометрии: куб (BoxGeometry), сфера (SphereGeometry) и цилиндр (CylinderGeometry). Для каждой геометрии создаётся меш с соответствующим материалом, и они позиционируются в пространстве.

const mesh1 = new THREE.Mesh(geometry1, material1); // Куб
const mesh2 = new THREE.Mesh(geometry2, material2); // Сфера
const mesh3 = new THREE.Mesh(geometry3, material3); // Цилиндр
mesh2.position.x = -200;
mesh3.position.x = 200;
scene.add(mesh1);
scene.add(mesh2);
scene.add(mesh3);

На этом этапе у нас есть полностью функциональная, но изолированная 3D-сцена Three.js. Ключевой шаг — связать её рендерер с канвасом, который контролирует Phaser.

Мост между движками: Рендерер Three.js и Extern

Сердце интеграции — создание рендерера Three.js, который будет использовать существующий WebGL-контекст игры Phaser. Это предотвращает создание второго канваса на странице.

const renderer = new THREE.WebGL1Renderer({
    canvas: this.sys.game.canvas,
    context: this.sys.game.context,
    antialias: true,
});
renderer.setPixelRatio(1);
renderer.setSize(800, 600);
renderer.autoClear = false; // Критически важно!

Параметр autoClear: false указывает Three.js не очищать буферы цвета и глубины перед своим рендером. Если бы он это делал, он стирал бы фон и другие объекты, отрисованные Phaser ранее (например, наш 'bg').

Затем создаётся специальный объект Phaser — Extern.

const view = this.add.extern();

Этот объект служит хуком. Его метод render будет вызываться движком Phaser на каждом кадре в процессе отрисовки. Внутри этого метода мы и выполняем рендеринг нашей сцены Three.js.

Функция рендеринга и синхронизация состояния

Функция view.render — это место, где происходит магия синхронизации. Phaser вызывает её, передавая свои внутренние WebGL-утилиты.

view.render = (webGLRenderer, drawingContext, calcMatrix) => {
    renderer.resetState(); // Сброс состояния GL для Three.js

    webGLRenderer.glWrapper.updateBindingsFramebuffer({
        bindings: {
            framebuffer: drawingContext.framebuffer
        }
    }, true); // Привязка фреймбуфера Phaser

    // Анимация вращения объектов
    mesh1.rotation.x += 0.005;
    mesh1.rotation.y += 0.01;
    mesh2.rotation.x -= 0.005;
    mesh2.rotation.y += 0.02;
    mesh3.rotation.x += 0.03;
    mesh3.rotation.y += 0.02;

    renderer.render(scene, camera); // Отрисовка сцены Three.js
};

Вызов renderer.resetState() критически важен. Three.js управляет состоянием WebGL (шейдеры, буферы, блендинг) по-своему. Phaser, отрисовывая свои 2D-объекты до вызова этой функции, оставляет контекст в своём состоянии. resetState() сбрасывает его к нейтральному, понятному для Three.js, предотвращая графические артефакты или падения.

Строка с updateBindingsFramebuffer гарантирует, что Three.js будет рисовать в тот же фреймбуфер, который в данный момент активен у Phaser, обеспечивая корректное совмещение 2D и 3D-слоёв. После этого выполняется стандартный рендеринг Three.js renderer.render(scene, camera). Анимация вращения объектов обновляется прямо здесь, на каждом кадре.

Что попробовать дальше

Интеграция Phaser и Three.js через Extern объект — это мощный и элегантный способ комбинировать сильные стороны обоих движков. Вы получаете простоту 2D-игростроения от Phaser и практически безграничные возможности 3D-визуализации от Three.js в одном проекте. Для экспериментов попробуйте: 1. Добавить физику от Phaser (this.physics.add.sprite) и расположить 2D-спрайты, которые будут "парить" перед вращающимися 3D-фигурами. 2. Использовать THREE.Raycaster для реализации взаимодействия (кликов, наведения) с 3D-объектами и передавать эти события в логику Phaser. 3. Заменить статичный фон Phaser на динамическое небо или частицы, созданные в Three.js, переключив renderer.autoClear на true и удалив фоновую картинку.