While in most web apps you’ll likely be rendering to the DOM, there are a few special cases where you might want to use Vue for something else. Say you’re developing an app with WebGL and you’d like to describe it with Vue as a tree of components. While Vue doesn’t explicitly support this at the moment, it’s entirely possible to implement yourself, as you’ll discover below.
We’ll be using pixi.js for this guide, so be sure to install that via npm. This is an advanced article, and as such, it is assumed that you already have a skeleton app prepared with webpack and vue 2.2+. Additionally, explanation will largely happen through code comments due to the complex nature of the components.
The goal will be to produce a set of three Vue components, the renderer, container, and a sprite component in order to draw textures in a 2D WebGL canvas with pixi.js.
The end result should look something like this:
Note: This is not a guide on implementing a complete PIXI renderer in Vue, just the basics. You’ll have to handle anything further yourself if you intend to do something more serious.
This is the component that initializes our PIXI stage and provides the PIXI objects to all of its descendants. (via. Vue 2.2+'s provide / inject system.)
<template>
<div class="pixi-renderer">
<canvas ref="renderCanvas"></canvas>
<!-- All child <template> elements get added in here -->
<slot></slot>
</div>
</template>
<script>
import Vue from 'vue';
import * as PIXI from 'pixi.js';
export default {
data() {
return {
// These need to be contained in an object because providers are not reactive.
PIXIWrapper: {
// Expose PIXI and the created app to all descendants.
PIXI,
PIXIApp: null,
},
// Expose the event bus to all descendants so they can listen for the app-ready event.
EventBus: new Vue()
}
},
// Allows descendants to inject everything.
provide() {
return {
PIXIWrapper: this.PIXIWrapper,
EventBus: this.EventBus
}
},
mounted() {
// Determine the width and height of the renderer wrapper element.
const renderCanvas = this.$refs.renderCanvas;
const w = renderCanvas.offsetWidth;
const h = renderCanvas.offsetHeight;
// Create a new PIXI app.
this.PIXIWrapper.PIXIApp = new PIXI.Application(w, h, {
view: renderCanvas,
backgroundColor: 0x1099bb
});
this.EventBus.$emit('ready');
}
}
</script>
<style scoped>
canvas {
width: 100%;
height: 100%;
}
</style>
This component primarily does two things.
The container component can contain an arbitrary amount of sprites or other containers, allowing for group nesting.
<script>
export default {
// Inject the EventBus and PIXIWrapper objects from the ancestor renderer component.
inject: ['EventBus', 'PIXIWrapper'],
// Take properties for the x and y position. (Basic, no validation)
props: ['x', 'y'],
data() {
return {
// Keep a reference to the container so children can be added to it.
container: null
}
},
// At the current time, Vue does not allow empty components to be created without a DOM element if they have children.
// To work around this, we create a tiny render function that renders to <template><!-- children --></template>.
render: function(h) {
return h('template', this.$slots.default)
},
created() {
// Create a new PIXI container and set some default values on it.
this.container = new this.PIXIWrapper.PIXI.Container();
// You should probably use computed properties to set the position instead.
this.container.x = this.x || 0;
this.container.y = this.y || 0;
// Allow the container to be interacted with.
this.container.interactive = true;
// Forward PIXI's pointerdown event through Vue.
this.container.on('pointerdown', () => this.$emit('pointerdown', this.container));
// Once the PIXI app in the renderer component is ready, add this container to its parent.
this.EventBus.$on('ready', () => {
if (this.$parent.container) {
// If the parent is another container, add to it.
this.$parent.container.addChild(this.container)
} else {
// Otherwise it's a direct descendant of the renderer stage.
this.PIXIWrapper.PIXIApp.stage.addChild(this.container)
}
// Emit a Vue event on every tick with the container and tick delta for an easy way to do frame-by-frame animation.
// (Not performant)
this.PIXIWrapper.PIXIApp.ticker.add(delta => this.$emit('tick', this.container, delta))
})
}
}
</script>
The container component takes two propertis, x and y, for position and emits two events, pointerdown and tick to handle clicking and frame updates, respectively. It can also have any number of containers or sprites as children.
The sprite component is almost the same as the container component, but with some extra tweaks for PIXI’s Sprite API.
<script>
export default {
inject: ['EventBus', 'PIXIWrapper'],
// x, y define the sprite's position in the parent.
// imagePath is the path to the image on the server to render as the sprite.
props: ['x', 'y', 'imagePath'],
data() {
return {
sprite: null
}
},
render(h) { return h() },
created() {
this.sprite = this.PIXIWrapper.PIXI.Sprite.fromImage(this.imagePath);
// Set the initial position.
this.sprite.x = this.x || 0;
this.sprite.y = this.y || 0;
this.sprite.anchor.set(0.5);
// Opt-in to interactivity.
this.sprite.interactive = true;
// Forward the pointerdown event.
this.sprite.on('pointerdown', () => this.$emit('pointerdown', this.sprite));
// When the PIXI renderer starts.
this.EventBus.$on('ready', () => {
// Add the sprite to the parent container or the root app stage.
if (this.$parent.container) {
this.$parent.container.addChild(this.sprite);
} else {
this.PIXIWrapper.PIXIApp.stage.addChild(this.sprite);
}
// Emit an event for this sprite on every tick.
// Great way to kill performance.
this.PIXIWrapper.PIXIApp.ticker.add(delta => this.$emit('tick', this.sprite, delta));
})
}
}
</script>
The sprite is pretty much the same as a container, except that it has an imagePath prop which allows you to choose what image to load and display from the server.
A simple Vue app that uses these three components to render the image at the start of the article:
<template>
<div id="app">
<pixi-renderer>
<container
:x="200" :y="400"
@tick="tickInfo" @pointerdown="scaleObject"
>
<sprite :x="0" :y="0" imagePath="./assets/vue-logo.png"/>
</container>
</pixi-renderer>
</div>
</template>
<script>
import PixiRenderer from './PIXIRenderer.vue'
import Sprite from './PIXISprite.vue'
import Container from './PIXIContainer.vue'
export default {
components: {
PixiRenderer,
Sprite,
Container
},
methods: {
scaleObject(container) {
container.scale.x *= 1.25;
container.scale.y *= 1.25;
},
tickInfo(container, delta) {
console.log(`Tick delta: ${delta}`)
}
}
}
</script>
<style>
#app {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
</style>
👉 Hopefully your knowledge of what can be done with Vue components has now been significantly expanded and the ideas are flowing like a waterfall. These are practically unexplored grounds, so there’s plenty you could choose to do!
Tread carefully! (or don’t!)
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
While we believe that this content benefits our community, we have not yet thoroughly reviewed it. If you have any suggestions for improvements, please let us know by clicking the “report an issue“ button at the bottom of the tutorial.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!
Almost 4 years old article but very interesting. I would like to deepen your work and continue what you have started. Can you tell me why the tickers are slow? Do you have any idea how to improve the performance to be as close as that of PIXI? Cheers!