The markdown source for this tutorial is not only used to generate this
web page, it's also used to generate the JavaScript for the above demo.
We use a small Python script for weaving (generating HTML) and tangling
(generating JS). In the code samples, you'll often see
// TODO: <some task>
. These are special markers that get replaced by
subsequent code blocks.
First, create a text file called triangle.html
and fill it with the following HTML. This creates
a mobile-friendly page with a full-screen canvas.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Filament Tutorial</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1">
<style>
body { margin: 0; overflow: hidden; }
canvas { touch-action: none; width: 100%; height: 100%; }
</style>
</head>
<body>
<canvas></canvas>
<script src="filament.js"></script>
<script src="//unpkg.com/gl-matrix@2.8.1"></script>
<script src="triangle.js"></script>
</body>
</html>
The above HTML loads three JavaScript files:
filament.js
does a couple things:
gl-matrix-min.js
is a small library that provides vector math functionality.triangle.js
will contain your application code.Go ahead and create triangle.js
with the following content.
class App {
constructor() {
// TODO: create entities
this.render = this.render.bind(this);
this.resize = this.resize.bind(this);
window.addEventListener('resize', this.resize);
window.requestAnimationFrame(this.render);
}
render() {
// TODO: render scene
window.requestAnimationFrame(this.render);
}
resize() {
// TODO: adjust viewport and canvas
}
}
Filament.init(['triangle.filamat'], () => { window.app = new App() } );
The two calls to bind()
allow us to pass instance methods as callbacks for animation and resize
events.
Filament.init()
consumes two things: a list of asset URLs and a callback.
The callback will be triggered only after all assets finish downloading and the Filament module has
become ready. In our callback, we simply instantiated the App
object, since we'll do most of the
work in its constructor. We also set the app instance into a Window
property to make it accessible
from the developer console.
Go ahead and download triangle.filamat and place it in your project folder. This is a material package, which is a binary file that contains shaders and other bits of data that define a PBR material. We'll learn more about material packages in the next tutorial.
Because of CORS restrictions, your web app cannot fetch the material package directly from the file system. One way around this is to create a temporary server using Python or node:
python3 -m http.server # Python 3
python -m SimpleHTTPServer # Python 2.7
npx http-server -p 8000 # nodejs
To see if this works, navigate to http://localhost:8000 and check if you can load the page without any errors appearing in the developer console.
Take care not to use Python's simple server in production since it does not serve WebAssembly files with the correct MIME type.
We now have a basic skeleton that can respond to paint and resize events. Let's start adding Filament objects to the app. Insert the following code into the top of the app constructor.
this.canvas = document.getElementsByTagName('canvas')[0];
const engine = this.engine = Filament.Engine.create(this.canvas);
The above snippet creates the Engine
by passing it a canvas DOM object. The engine needs the
canvas in order to create a WebGL 2.0 context in its contructor.
The engine is a factory for many Filament entities, including Scene
, which is a flat container of
entities. Let's go ahead and create a scene, then add a blank entity called triangle
into the
scene.
this.scene = engine.createScene();
this.triangle = Filament.EntityManager.get().create();
this.scene.addEntity(this.triangle);
Filament uses an Entity-Component System. The triangle entity in the above snippet does not yet have an associated component. Later in the tutorial we will make it into a renderable. Renderables are entities that have associated draw calls.
Next we'll create two typed arrays: a positions array with XY coordinates for each vertex, and a colors array with a 32-bit word for each vertex.
const TRIANGLE_POSITIONS = new Float32Array([
1, 0,
Math.cos(Math.PI * 2 / 3), Math.sin(Math.PI * 2 / 3),
Math.cos(Math.PI * 4 / 3), Math.sin(Math.PI * 4 / 3),
]);
const TRIANGLE_COLORS = new Uint32Array([0xffff0000, 0xff00ff00, 0xff0000ff]);
Next we'll use the positions and colors buffers to create a single VertexBuffer
object.
const VertexAttribute = Filament.VertexAttribute;
const AttributeType = Filament.VertexBuffer$AttributeType;
this.vb = Filament.VertexBuffer.Builder()
.vertexCount(3)
.bufferCount(2)
.attribute(VertexAttribute.POSITION, 0, AttributeType.FLOAT2, 0, 8)
.attribute(VertexAttribute.COLOR, 1, AttributeType.UBYTE4, 0, 4)
.normalized(VertexAttribute.COLOR)
.build(engine);
this.vb.setBufferAt(engine, 0, TRIANGLE_POSITIONS);
this.vb.setBufferAt(engine, 1, TRIANGLE_COLORS);
The above snippet first creates aliases for two enum types, then constructs the vertex buffer using
its Builder
method. After that, it pushes two buffer objects into the appropriate slots using
setBufferAt
.
In the Filament API, the above builder pattern is often used for constructing objects in lieu of long argument lists. The daisy chain of function calls allows the client code to be somewhat self-documenting.
Our app sets up two buffer slots in the vertex buffer, and each slot is associated with a single attribute. Alternatively, we could have interleaved or concatenated these attributes into a single buffer slot.
Next we'll construct an index buffer. The index buffer for our triangle is trivial: it simply holds the integers 0,1,2.
this.ib = Filament.IndexBuffer.Builder()
.indexCount(3)
.bufferType(Filament.IndexBuffer$IndexType.USHORT)
.build(engine);
this.ib.setBuffer(engine, new Uint16Array([0, 1, 2]));
Note that constructing an index buffer is similar to constructing a vertex buffer, but it only has one buffer slot, and it can only contain two types of data (USHORT or UINT).
Next let's construct an actual Material
from the material package that was downloaded (the
material is an object; the package is just a binary blob), then extract the default
MaterialInstance
from the material object. Material instances have concrete values for their
parameters, and they can be bound to renderables. We'll learn more about material instances in the
next tutorial.
After extracting the material instance, we can finally create a renderable component for the triangle by setting up a bounding box and passing in the vertex and index buffers.
const mat = engine.createMaterial('triangle.filamat');
const matinst = mat.getDefaultInstance();
Filament.RenderableManager.Builder(1)
.boundingBox({ center: [-1, -1, -1], halfExtent: [1, 1, 1] })
.material(0, matinst)
.geometry(0, Filament.RenderableManager$PrimitiveType.TRIANGLES, this.vb, this.ib)
.build(engine, this.triangle);
Next let's wrap up the initialization routine by creating the swap chain, renderer, camera, and view.
this.swapChain = engine.createSwapChain();
this.renderer = engine.createRenderer();
this.camera = engine.createCamera(Filament.EntityManager.get().create());
this.view = engine.createView();
this.view.setCamera(this.camera);
this.view.setScene(this.scene);
// Set up a blue-green background:
this.renderer.setClearOptions({clearColor: [0.0, 0.1, 0.2, 1.0], clear: true});
// Adjust the initial viewport:
this.resize();
At this point, we're done creating all Filament entities, and the code should run without errors. However the canvas is still blank!
Recall that our App class has a skeletal render method, which the browser calls every time it needs to repaint. Often this is 60 times a second.
render() {
// TODO: render scene
window.requestAnimationFrame(this.render);
}
Let's flesh this out by rotating the triangle and invoking the Filament renderer. Add the following code to the top of the render method.
// Rotate the triangle.
const radians = Date.now() / 1000;
const transform = mat4.fromRotation(mat4.create(), radians, [0, 0, 1]);
const tcm = this.engine.getTransformManager();
const inst = tcm.getInstance(this.triangle);
tcm.setTransform(inst, transform);
inst.delete();
// Render the frame.
this.renderer.render(this.swapChain, this.view);
The first half of our render method obtains the transform component of the triangle entity and uses gl-matrix to generate a rotation matrix.
The second half of our render method invokes the Filament renderer on the view, and tells the
Filament engine to execute its internal command buffer. The Filament renderer can tell the app
that it wants to skip a frame, hence the if
statement.
One last step. Add the following code to the resize method. This adjusts the resolution of the
rendering surface when the window size changes, taking devicePixelRatio
into account for high-DPI
displays. It also adjusts the camera frustum accordingly.
const dpr = window.devicePixelRatio;
const width = this.canvas.width = window.innerWidth * dpr;
const height = this.canvas.height = window.innerHeight * dpr;
this.view.setViewport([0, 0, width, height]);
const aspect = width / height;
const Projection = Filament.Camera$Projection;
this.camera.setProjection(Projection.ORTHO, -aspect, aspect, -1, 1, 0, 1);
You should now have a spinning triangle! The completed JavaScript is available here.
In the next tutorial, we'll take a closer look at Filament materials and 3D rendering.