Hello world
A lit cube turning at a rate a DriftScript rule sets. The one to copy out to start a game.
Starts examples/starter in this page, on WebGPU where your browser has it.
Hello world is the chapter that walks through it.
From a checkout of the engine, npm run examples serves it at /starter/.
Source
main.ts
examples/starter/main.ts
/**
* The smallest thing that is still a game: a lit cube on a ground plane, turning on a fixed
* timestep, drawn through whichever backend the browser actually handed over, at a rate a
* DriftScript rule decides.
*
* Everything here is reached through the public barrel, because that barrel is the whole of
* what a consumer receives. A starter that reached past it would be demonstrating privileged
* access, not the engine.
*/
// #region imports
import {
Camera,
MeshBuilder,
SceneNode,
createEnvironment,
createRenderer,
startLoop,
} from '@driftengine/core';
import { bindModule } from '@driftengine/script';
import { loadModule, patchModule } from 'driftscript';
import * as spinScript from './spin.drs';
// #endregion
// #region renderer
const canvas = document.querySelector<HTMLCanvasElement>('#stage');
if (canvas === null) throw new Error('the page must carry <canvas id="stage">');
/*
* Asynchronous because it has to be: asking for a WebGPU adapter returns a promise, and there
* is no synchronous way to learn whether a usable device exists. The fall back to WebGL2 is
* automatic and silent, so `reason` is the only thing that says why. Print it: the first
* question asked about any picture is what drew it.
*/
const { renderer, backend, reason } = await createRenderer(canvas, {
maxDevicePixelRatio: 1.75,
});
const readout = document.querySelector('#backend');
if (readout !== null) readout.textContent = `${backend}: ${reason}`;
renderer.resize();
addEventListener('resize', () => renderer.resize());
// #endregion
// #region environment
/** Light, air and ground bounce. Chosen once, because these are allocation-sized decisions. */
const ENV = createEnvironment({
directionalDir: [0.4, 0.7, 0.35],
directionalColor: [1, 0.96, 0.88],
ambient: [0.2, 0.22, 0.28],
ambientGround: [0.08, 0.08, 0.1],
emissiveGain: 0,
nightFactor: 0,
fogColor: [0.16, 0.18, 0.24],
fogDensity: 0.004,
fogHeightFalloff: 0.03,
fogBaseY: 0,
});
// #endregion
// #region meshes
/*
* Colour is vertex data here, which is what lets a whole world be flat-shaded draw calls.
* `addBox` takes a centre and *half* extents, so this ground is 24 units across and the cube
* is two units on a side.
*/
const shapes = new MeshBuilder();
shapes.addBox([0, 1, 0], [1, 1, 1], [0.85, 0.45, 0.25]);
const cube = renderer.createMesh(shapes.build());
const slab = new MeshBuilder();
slab.addBox([0, -0.25, 0], [12, 0.25, 12], [0.3, 0.32, 0.36]);
const ground = renderer.createMesh(slab.build());
// #endregion
// #region scene
const spinner = new SceneNode();
spinner.setPosition(0, 1, 0);
const stillness = new SceneNode();
stillness.updateWorld();
const camera = new Camera();
camera.position[0] = 6;
camera.position[1] = 4.5;
camera.position[2] = 8;
camera.lookAt(0, 1, 0);
// #endregion
// #region script
/*
* How fast the cube turns is a rule, and rules live in DriftScript: `spin.drs`. The bundler compiles
* the file, `loadModule` makes a module of it, and `bindModule` gives it the engine capabilities it
* imports. Saving the file while the page is open replaces its function in place.
*/
const spinModule = loadModule(spinScript as Record<string, unknown>);
const bound = bindModule(spinModule, {});
if (!bound.bound) throw new Error(bound.reason);
const rules = spinModule.exports as unknown as { spinRate(time: number): number };
if (import.meta.hot) {
import.meta.hot.accept('./spin.drs', (next) => {
if (next !== undefined) patchModule(spinModule, next as Record<string, unknown>);
});
}
// #endregion
// #region loop
/*
* Two angles, because that is what `alpha` is for. The simulation advances in fixed steps and
* the display does not, so a frame almost never lands on a step boundary: drawing `spin`
* directly judders at any refresh rate that is not a multiple of the step. Interpolating
* between the last two states is the whole reason the loop hands `alpha` over.
*/
let spin = 0;
let previousSpin = 0;
let time = 0;
startLoop({
simulate(dt) {
previousSpin = spin;
time += dt;
spin += dt * rules.spinRate(time);
},
render(alpha) {
spinner.setRotationAxisAngle(0, 1, 0, previousSpin + (spin - previousSpin) * alpha);
spinner.updateWorld();
camera.updateMatrices(canvas.height > 0 ? canvas.width / canvas.height : 1);
renderer.beginFrame([0.05, 0.06, 0.09]);
renderer.bindMeshPass(camera, ENV);
renderer.drawMesh(ground, stillness.worldMatrix);
renderer.drawMesh(cube, spinner.worldMatrix);
renderer.endFrame();
},
});
// #endregionspin.drs
examples/starter/spin.drs
// How the cube turns. The page owns the cube; this file only says how fast it goes.
//
// Save it while the page is open and the cube takes the new rate without the page reloading. Try a
// steady 2, a cube that stops every few seconds, or one that turns the other way.
import { sin } from "std/math"
// #region rate
// Radians a second: a steady turn that quickens and eases every few seconds.
fn spinRate(time: f32) -> f32 {
return 0.8 + 0.4 * math.sin(time * 0.5)
}
// #endregionvite.config.ts
examples/starter/vite.config.ts
/**
* The build a copied starter needs: Vite, and DriftScript's plugin, which compiles each `.drs`
* import against what the engine provides.
*
* Inside this repository the examples' own config does the same job, so this file only matters once
* the folder is copied into a project of its own.
*/
// #region plugin
import { fileURLToPath } from 'node:url';
import { driftScript } from 'driftscript/vite';
import { defineConfig } from 'vite';
export default defineConfig(({ command }) => ({
plugins: [
driftScript({
/* The engine's capabilities, as data: every `drift/*` function, its types and its effects. */
capabilities: fileURLToPath(import.meta.resolve('@driftengine/script/capabilities.json')),
/* The engine modules a script in this game may import. Add one here when a script needs it;
an import of a module not named is refused when the file compiles, naming the module. */
manifest: { name: 'my-game', provides: ['drift/random', 'drift/scene', 'drift/input'] },
/* `vite build` compiles for shipping: the editor's metadata stays out of the bundle. */
mode: command === 'build' ? 'production' : 'development',
}),
],
build: {
target: 'es2022',
/* Just above the engine's WebGPU renderer, one module of about 2,100 kB minified that loads only
where WebGPU does. Vite warns past 500 kB by default; anything larger than the renderer still
warns. */
chunkSizeWarningLimit: 2200,
},
}));
// #endregion