Examples

Display

A video settings screen drawn from what the host says it can do, and why where it cannot.

Starts examples/display in this page, on WebGPU where your browser has it.

Open on its own · Source

Display and the host is the chapter that walks through it.

From a checkout of the engine, npm run examples serves it at /display/.

Source

main.ts

examples/display/main.ts
/**
 * A video settings screen, asked of the host rather than assumed.
 *
 * Behind the panel, a test card turns; it stops while the page does not have focus. The panel asks
 * the host what it can do and draws each control from the answer: the window's mode and size, the
 * display it is on, the screen's orientation, safe area and wake lock, whether there is a way to
 * quit, and a file to save the settings to. In a browser several answers are no, and the panel says
 * which and why instead of offering a button that does nothing. The strip recolours the cursor.
 */
import {
  BrowserDisplay,
  BrowserFileDialogs,
  BrowserLifecycle,
  BrowserScreenPresentation,
  MeshBuilder,
  computeLightMatrix,
  createEnvironment,
  pixelCursor,
  requestFullscreenOnGesture,
} from '@driftengine/core';
import type {
  DisplayControl,
  FileDialogs,
  Lifecycle,
  ScreenOrientation,
  ScreenPresentation,
  Vec3,
} from '@driftengine/core';
import { controls, flag, openStage } from '../common/stage';

const stage = await openStage({
  directionalShadows: true,
  outputTransform: 'aces',
  sceneSamples: 4,
});
const { renderer, camera, canvas } = stage;

// #region host
/* The host, as four capabilities. A shell passes its own; a page in a browser uses these. */
const display: DisplayControl = new BrowserDisplay(document.documentElement);
const screen: ScreenPresentation = new BrowserScreenPresentation();
const lifecycle: Lifecycle = new BrowserLifecycle();
const files: FileDialogs = new BrowserFileDialogs();
// #endregion

const $ = <T extends HTMLElement>(id: string): T => {
  const found = document.getElementById(id);
  if (found === null) throw new Error(`the page has no #${id}`);
  return found as T;
};
const say = (id: string, text: string): void => {
  $(id).textContent = text;
};

// #region window
/* Each control is drawn from the host's own answer, read when it is drawn. */
function drawWindow(): void {
  const { width, height } = display.size();
  say('mode', display.mode());
  say('size', `${Math.round(width)} by ${Math.round(height)}`);
  /* A browser never sizes its own window; a shell will, except while it covers a display. */
  $<HTMLButtonElement>('smaller').disabled = !display.canSetSize();
  say('size-note', display.canSetSize() ? '' : 'The window is sized by whoever holds it.');
  const shown = display.displays();
  say(
    'displays',
    shown
      .map(
        (d) =>
          `${d.label} at ${d.scale}x, ${d.refreshHz === null ? 'rate not said' : `${d.refreshHz} Hz`}`,
      )
      .join('; '),
  );
}

$('fullscreen').addEventListener('click', () => {
  /* Fullscreen needs a gesture, which this click is. `false` means refused, never an error. */
  void display
    .setMode(display.mode() === 'fullscreen' ? 'windowed' : 'fullscreen')
    .then(drawWindow);
});
$('smaller').addEventListener('click', () => {
  const { width, height } = display.size();
  void display.setSize(Math.round(width * 0.8), Math.round(height * 0.8)).then(drawWindow);
});
addEventListener('resize', drawWindow);
document.addEventListener('fullscreenchange', drawWindow);
// #endregion

// #region screen
/* What a phone has instead of a window. The safe area is drawn as an outline, so a notch shows. */
function drawScreen(): void {
  say('orientation', screen.orientation());
  const inset = screen.safeArea();
  const frame = $('safe-area');
  frame.style.inset = `${inset.top}px ${inset.right}px ${inset.bottom}px ${inset.left}px`;
  say('insets', `${inset.top}, ${inset.right}, ${inset.bottom}, ${inset.left}`);
}

for (const choice of ['portrait', 'landscape', 'free'] as const) {
  $(`lock-${choice}`).addEventListener('click', () => {
    const wanted: ScreenOrientation | null = choice === 'free' ? null : choice;
    void screen.lockOrientation(wanted).then((done) => {
      say('lock-note', done ? `Locked to ${choice}.` : 'This platform will not lock it.');
      drawScreen();
    });
  });
}

let awake = false;
$('awake').addEventListener('click', () => {
  awake = !awake;
  void screen.keepAwake(awake).then((held) => {
    say(
      'awake-note',
      awake
        ? held
          ? 'The screen stays on.'
          : 'There is no wake lock here.'
        : 'The screen may sleep.',
    );
  });
});
// #endregion

// #region lifecycle
/* The test card stops while the page is not in front, and Quit exists only where it works. */
let focused = true;
lifecycle.onFocusChange((now) => {
  focused = now;
  say('focus', now ? 'in front' : 'behind something');
});
lifecycle.onQuitRequest(() => {
  /* A shell waits for this before it closes; a browser never calls it. */
  saveSettings();
});
$('quit').hidden = !lifecycle.canQuit;
say(
  'quit-note',
  lifecycle.canQuit ? '' : 'A page cannot close its own tab, so there is no Quit button here.',
);
$('quit').addEventListener('click', () => lifecycle.requestQuit());
// #endregion

// #region files
/* The settings as a file the player keeps. A shell's picker is a native dialog; a browser's is the
   File System Access picker where it has one, and throws where it has none. */
interface Settings {
  mode: string;
  cursor: string;
}
let cursorColor = flag('cursor', 'amber');

function saveSettings(): void {
  const settings: Settings = { mode: display.mode(), cursor: cursorColor };
  const bytes = new TextEncoder().encode(JSON.stringify(settings, null, 2));
  files.saveFile('settings.json', bytes).then(
    (saved) => say('file-note', saved ? 'Saved.' : 'Not saved.'),
    () => say('file-note', 'There is no file picker in this browser.'),
  );
}

$('save').addEventListener('click', saveSettings);
$('open').addEventListener('click', () => {
  files.openFile(['.json']).then(
    (opened) => {
      if (opened === null) return say('file-note', 'Nothing opened.');
      const settings = JSON.parse(new TextDecoder().decode(opened.bytes)) as Partial<Settings>;
      if (typeof settings.cursor === 'string') applyCursor(settings.cursor);
      say('file-note', `Opened ${opened.name}.`);
    },
    () => say('file-note', 'There is no file picker in this browser.'),
  );
});
// #endregion

// #region cursor
/* A cursor drawn from a grid of characters, at the device pixel ratio, recoloured at will. */
const ARROW = [
  'x.......',
  'xx......',
  'xox.....',
  'xoox....',
  'xooox...',
  'xoooox..',
  'xooxxxx.',
  'xx......',
];
const INKS: Record<string, string> = { amber: '#f0a437', cyan: '#4fd6e0', white: '#f2f4f8' };

function applyCursor(name: string): void {
  cursorColor = name in INKS ? name : 'amber';
  canvas.style.cursor = pixelCursor(
    ARROW,
    { x: '#0b0d12', o: INKS[cursorColor] as string },
    {
      scale: 3,
      hotspotX: 0,
      hotspotY: 0,
    },
  );
}
applyCursor(cursorColor);
controls([
  {
    key: 'cursor',
    label: 'cursor',
    value: cursorColor,
    options: Object.keys(INKS).map((ink) => ({ text: ink, value: ink })),
    change: (value) => applyCursor(value),
  },
]);
// #endregion

// #region handheld
/* On a phone, the first tap anywhere fills the screen, once. On a desktop this does nothing. */
requestFullscreenOnGesture(document.documentElement);
// #endregion

drawWindow();
drawScreen();
say('focus', 'in front');
addEventListener('resize', drawScreen);

/* The test card: three cubes in the primaries over a grid, turning. */
const floor = new MeshBuilder().addBox([0, -0.05, 0], [8, 0.05, 8], [0.14, 0.15, 0.18]);
for (let line = -8; line <= 8; line += 1) {
  floor.addBox([line, 0.002, 0], [0.01, 0.002, 8], [0.3, 0.31, 0.35]);
  floor.addBox([0, 0.002, line], [8, 0.002, 0.01], [0.3, 0.31, 0.35]);
}
const floorMesh = renderer.createMesh(floor.build());
const COLORS: Vec3[] = [
  [0.85, 0.2, 0.2],
  [0.25, 0.75, 0.3],
  [0.2, 0.4, 0.9],
];
const cubes = COLORS.map((color) =>
  renderer.createMesh(new MeshBuilder().addBox([0, 0, 0], [0.6, 0.6, 0.6], color).build()),
);
const env = createEnvironment({
  directionalDir: [0.4, 0.85, 0.35],
  directionalColor: [1.4, 1.35, 1.25],
  ambient: [0.3, 0.32, 0.4],
  ambientGround: [0.1, 0.1, 0.12],
});
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.6;
env.shadowDepthSpan = computeLightMatrix(
  env.directionalDir,
  0,
  1,
  0,
  10,
  renderer.shadowMapSize,
  lightMatrix,
);
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const models = cubes.map(() => new Float32Array(IDENTITY));
let angle = 0;

stage.run({
  simulate(dt) {
    if (focused) angle += dt * 0.7;
  },
  render() {
    camera.fovYDeg = 45;
    camera.position[0] = 0;
    camera.position[1] = 4.5;
    camera.position[2] = 9;
    camera.lookAt(-1.2, 0.6, 0);
    cubes.forEach((_, i) => {
      const a = angle + i * 0.9;
      const model = models[i] as Float32Array;
      model.set([
        Math.cos(a),
        0,
        -Math.sin(a),
        0,
        0,
        1,
        0,
        0,
        Math.sin(a),
        0,
        Math.cos(a),
        0,
        (i - 1) * 2.2 - 1.2,
        0.6,
        0,
        1,
      ]);
    });
    renderer.beginShadowPass(lightMatrix, 'static');
    renderer.drawShadowCasters((sink) => {
      cubes.forEach((cube, i) => sink.mesh(cube, models[i] as Float32Array));
    });
    renderer.endShadowPass();
    renderer.beginFrame([0.07, 0.08, 0.11]);
    renderer.bindMeshPass(camera, env);
    renderer.drawMesh(floorMesh, IDENTITY);
    cubes.forEach((cube, i) => renderer.drawMesh(cube, models[i] as Float32Array));
    renderer.endFrame();
  },
});