While developing one of our potential projects, I became interested in modern three-dimensional visualisation technologies. A long time ago, I wrote a lot of code using the OpenGL stack, and I wanted to refresh my memory of contemporary concepts quickly and sketch out a prototype. Several circumstances immediately got in my way.
First, beginning with macOS Mojave, Apple moved entirely to its own Metal 2 framework, not only killing support for NVIDIA CUDA—goodbye MATLAB, Photoshop, several dozen useful utilities, and all professional CUDA C/C++ development—but also making OpenGL development more difficult.
Second, interface development is now built on web technologies, and this is the field advancing fastest of all. To create a respectable, reusable solution that will not be declared deprecated six months after release, it therefore seems wise to follow the web trend. Even the development environment I sometimes use for Haskell is Visual Studio Code, built on Electron—although SpaceVim has now almost completely replaced it for me. The Ghost application in which this text is being written uses the same platform, as do Slack, Skype, and many other common programs.
For my example, I chose Three.js, which provides high-level interfaces to WebGL, which I hope follows the OpenGL principles familiar to me. Having never done web development before, I found all the surrounding tooling deeply confusing. This note records my path towards creating the simplest possible project.
JavaScript rules the world of web development, while the combination of Node and npm rules the world of JavaScript. Together, they allow JavaScript to be used not only for browser development but also for writing convenient command-line tools—including tools for building traditional browser projects. 😊
To begin, however, let us create the silliest possible code, consisting of three files: markup, styles, and a rendering script.
<!doctype html>
<html>
<head>
<title>Example</title>
<!-- Include the stylesheet -->
<link rel="stylesheet" type="text/css" href="style.css" />
</head>
<body>
<!-- Create the container in which rendering will take place -->
<div id="container"></div>
<!-- Include Three.js from the official website -->
<script src="https://threejs.org/build/three.js"></script>
<!-- Include the script that will perform the rendering -->
<script type="text/javascript" src="app.js"></script>
</body>
</html>
body {
background-color: #000;
margin: 0px;
overflow: hidden;
color: white;
text-align: center;
}
#container {
position: absolute;
width: 100%;
height: 100%;
}
var container = document.querySelector('#container');
var scene = new THREE.Scene();
scene.background = new THREE.Color('skyblue');
var fov = 35; // AKA Field of View
var aspect = container.clientWidth / container.clientHeight;
var near = 0.1; // the near clipping plane
var far = 100; // the far clipping plane
var camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.set(0, 0, 10);
var geometry = new THREE.BoxBufferGeometry(2, 2, 2);
var material = new THREE.MeshBasicMaterial();
var mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
var renderer = new THREE.WebGLRenderer();
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
renderer.render(scene, camera);
The result is a wonderful white square—actually a cube—against a blue background. We will learn to work with Three.js next time. For now, let us try to understand what is wrong here and why we should not simply continue developing with these three files.
We could, of course, work this way. But we encounter several limitations that people arriving from the sheltered world of back-end development find utterly intolerable:
- the inability to split code into modules;
- dependence on code hosted on a remote server;
- the limitations of vanilla JavaScript, which evolves unevenly across browsers;
- the absence of code checks or any CI procedures;
- the need to make changes in several places at once—HTML, CSS, and JavaScript;
- and much else besides.
We will tackle these ailments one by one.
Creating an npm project
First, let us create a project in our directory:
npm init -y
Wrote to /prjoect/path/package.json:
{
"name": "example-project",
"version": "1.0.0",
"description": "",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}The directory now looks like this:
tree .
.
├── app.js
├── index.html
├── package.json
└── style.css
0 directories, 4 filesWe could still edit package.json to specify the author and other metadata, but we will move on to the next steps.
Adding webpack
One of our main problems is, of course, the inability to use modules in our development. The excellent webpack utility solves this and other problems and is almost indispensable in every modern project. To use it, we need only run the installation command:
npm install --save-dev webpack webpack-cli
...
+ webpack-cli@3.3.7
+ webpack@4.39.3
added 453 packages from 237 contributors and audited 5286 packages in 13.776s
found 0 vulnerabilitiesWe can now build a single JavaScript “bundle” from several modules. Let us use this to add Three.js:
npm install --save three
...
+ three@0.108.0
added 1 package from 1 contributor and audited 5287 packages in 2.73s
found 0 vulnerabilitiesWe can now import Three.js in our app.js file:
var THREE = require('three');
...
The line loading the library from a third-party CDN can be removed from index.html. This is not the only change required in the HTML file, however. The build will not modify the JavaScript file or add new lines to the HTML; it will create a new JavaScript file containing all the code in use. By default, this bundle is placed at dist/main.js, so that is the path we will reference. Our final index.html looks like this:
<!doctype html>
<html>
<head>
<title>Example</title>
<!-- Include the stylesheet -->
<link rel="stylesheet" type="text/css" href="style.css" />
</head>
<body>
<!-- Create the container in which rendering will take place -->
<div id="container"></div>
<!-- Include the compiled JavaScript -->
<script type="text/javascript" src="./dist/main.js"></script>
</body>
</html>
How do we build the project now? We need to add the appropriate command to the scripts in package.json:
...
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack --mode production"
},
...
The project can now be built with the following command:
npm run build
> example-project@1.0.0 build /path/to/project
> webpack --mode production
Insufficient number of arguments or no entry found.
Alternatively, run 'webpack(-cli) --help' for usage info.
Hash: c8e9ac5d52d7c3c70555
Version: webpack 4.39.3
Time: 52ms
Built at: 04.09.2019 09:56:46
ERROR in Entry module not found: Error: Can't resolve './src' in '/path/to/project'
npm ERR! code ELIFECYCLE
npm ERR! errno 2
npm ERR! example-project@1.0.0 build: `webpack --mode production`
npm ERR! Exit status 2
npm ERR!
npm ERR! Failed at the example-project@1.0.0 build script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.
npm ERR! A complete log of this run can be found in:
npm ERR! /home/dir/.npm/_logs/2019-09-04T06_56_46_316Z-debug.logSomething appears to have gone wrong. Indeed, webpack looks for all JavaScript files in the src directory by default and expects the main file to be named index.js. Let us help it:
mkdir src
mv app.js src/index.js
npm run build
> example-project@1.0.0 build /path/to/project
> webpack --mode production
Hash: 9a8895c35652a46d21af
Version: webpack 4.39.3
Time: 706ms
Built at: 04.09.2019 10:05:35
Asset Size Chunks Chunk Names
main.js 587 KiB 0 [emitted] [big] main
Entrypoint main [big] = main.js
[0] ./src/index.js 842 bytes {0} [built]
+ 1 hidden module
...We will probably see several more warnings. They are related to the large size of the resulting bundle: we included the entire Three.js library, not just the functions we need. For now, we will ignore them and enjoy the result by opening index.html. The browser displays exactly the same white square on a blue background.
Bundling HTML
While manipulating the compilation of JavaScript files, we also had to change our HTML. There may be many more such changes in the future, so why not learn to generate part of that HTML automatically? Webpack can help us with this too. First, however, we need to learn how to configure it.
The entire webpack build configuration is described in a special file named webpack.config.js. Let us create a basic skeleton:
module.exports = {
// Specify which file is the entry point.
entry: './src/index.js',
// Put the compiled bundle in a file named main.js.
output: {
filename: "main.js"
},
module: {
rules: [
// Describe the transformation rules that will be
// applied to files of different types.
]
},
// Apply plugins that extend webpack's capabilities.
plugins: [
]
};
Running npm run build produces exactly the same result because we have effectively reproduced the default configuration. If we change output.filename, we will once again have to change index.html. To avoid this, we can use the magical HtmlWebpackPlugin. First, let us install it together with the HTML loader that we will also need:
npm install --save-dev html-webpack-plugin html-loader
...
+ html-loader@0.5.5
+ html-webpack-plugin@3.2.0
added 63 packages from 75 contributors and audited 5407 packages in 4.757s
found 0 vulnerabilitiesAs you can probably guess, we now have to modify our webpack configuration:
// Import the plugin module into our configuration.
var HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
entry: './src/index.js',
output: {
filename: "main.js"
},
module: {
rules: [
{
// For every HTML file
test: /\.html$/,
use: [
{
// Use html-loader
loader: "html-loader",
// Which also minifies our HTML
options: { minimize: true }
}
]
}
]
},
plugins: [
// Copy index.html into the dist directory
new HtmlWebpackPlugin({
template: "./index.html",
filename: "./index.html"
})
]
};
We will also remove the script invocation from index.html; webpack now adds it automatically:
<!doctype html>
<html>
<head>
<title>Example</title>
<!-- Include the stylesheet -->
<link rel="stylesheet" type="text/css" href="style.css" />
</head>
<body>
<!-- Create the container in which rendering will take place -->
<div id="container"></div>
</body>
</html>
After npm run build, the contents of dist now look like this:
tree dist
dist
├── index.html
└── main.js
0 directories, 2 filesThe new index.html contains no comments and includes the script invocation. We can therefore open it and see our familiar white square (NB: this is not true!). The script itself can now be renamed freely without breaking anything.
Bundling CSS
If we actually open index.html, we discover that I lied in the previous paragraph: there is no square because our container has zero dimensions—none of the styles were imported. We could of course copy them manually and everything would work. But why do that if webpack can take care of it?
As tradition demands, we begin by adding a pair of modules as development dependencies:
npm install --save-dev css-loader style-loader
...
+ style-loader@1.0.0
+ css-loader@3.2.0
added 16 packages from 47 contributors and audited 5543 packages in 3.974s
found 0 vulnerabilitiesWe also modify the webpack configuration by adding the following entry to module.rules:
...
{
test: /\.css$/,
use: [
{
loader: "style-loader"
},
{
loader: "css-loader"
}
]
}
...
We now need to specify where the stylesheet comes from. To do that, remove the stylesheet line from index.html and add an import to our JavaScript:
<!doctype html>
<html>
<head>
<title>Example</title>
</head>
<body>
<!-- Create the container in which rendering will take place -->
<div id="container"></div>
</body>
</html>
var css = require("./../style.css");
var THREE = require("three");
...
Using css-loader, webpack now loads the styles directly into our JavaScript, and we no longer have to copy anything. The commands npm install and npm run build now produce a fully working project without any manual finishing touches. This covers the creation of a basic web application project without any add-ons or extra features such as support for new versions of ECMAScript. We will discuss those another time.
The final project looks like this, with automatically downloaded or generated files hidden:
tree
.
├── index.html
├── package.json
├── src
│ └── index.js
├── style.css
└── webpack.config.js
1 directory, 5 filesThe list of files
{
"name": "example",
"version": "1.0.0",
"description": "",
"private": true,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack --mode production"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"css-loader": "^3.2.0",
"html-loader": "^0.5.5",
"html-webpack-plugin": "^3.2.0",
"style-loader": "^1.0.0",
"webpack": "^4.40.2",
"webpack-cli": "^3.3.9"
},
"dependencies": {
"three": "^0.108.0"
}
}
var HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
entry: './src/index.js',
output: {
filename: "main.js"
},
module: {
rules: [
{
// For every HTML file
test: /\.html$/,
use: [
{
// Use html-loader
loader: "html-loader",
// Which also minifies our HTML
options: { minimize: true }
}
]
},
{
test: /\.css$/,
use: [
{
loader: "style-loader"
},
{
loader: "css-loader"
}
]
}
]
},
plugins: [
// Copy index.html into the dist directory
new HtmlWebpackPlugin({
template: "./index.html",
filename: "./index.html"
})
]
};
<!doctype html>
<html>
<head>
<title>Example</title>
</head>
<body>
<!-- Create the container in which rendering will take place -->
<div id="container"></div>
</body>
</html>
body {
background-color: #000;
margin: 0px;
overflow: hidden;
color: white;
text-align: center;
}
#container {
position: absolute;
width: 100%;
height: 100%;
}
var css = require("./../style.css");
var THREE = require("three");
var container = document.querySelector('#container');
var scene = new THREE.Scene();
scene.background = new THREE.Color('skyblue');
var fov = 35; // AKA Field of View
var aspect = container.clientWidth / container.clientHeight;
var near = 0.1; // the near clipping plane
var far = 100; // the far clipping plane
var camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.set(0, 0, 10);
var geometry = new THREE.BoxBufferGeometry(2, 2, 2);
var material = new THREE.MeshBasicMaterial();
var mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
var renderer = new THREE.WebGLRenderer();
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
renderer.render(scene, camera);