Examples

Models

A turntable that reads glTF, OBJ and STL in a worker and streams them, or a model you drop.

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

Open on its own · Source

Importing models is the chapter that walks through it.

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

Source

main.ts

examples/models/main.ts
/**
 * A model on a turntable: one of the samples, or any file dropped on the page.
 *
 * A model file is read in a worker, which hands back a `.drft` container, and the container is
 * streamed onto the screen by `DrftLoader`: an outline first where the model is big enough to want
 * one, then the parts fading in a few a frame, then the finished model merged down to a draw per
 * material. A baked `.drft` fetched from a server takes the same path from its first byte; reading
 * a source format at load is for a file a person brings, and a bake is for a file you ship.
 */
import { DrftLoader, extensionOf, readerFor } from '@driftengine/assets';
import type { DrftFit } from '@driftengine/assets';
import { MeshBuilder, computeLightMatrix, createEnvironment } from '@driftengine/core';
import { createReadout } from '../common/readout';
import { controls, flag, openStage } from '../common/stage';
import type { ConvertReply, ConvertRequest } from './convert';

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

// #region load
/** Every model is fitted to the turntable: this wide, this tall, standing on it. */
const FIT: DrftFit = { footprint: 1.8, height: 1.6, baseY: 0.1 };
const worker = new Worker(new URL('./convert.ts', import.meta.url), { type: 'module' });
let loader: DrftLoader | null = null;

/** Send a file's bytes to the worker, and stream the container it answers with. */
function open(request: ConvertRequest): void {
  status = { label: request.name, line: 'READING' };
  worker.postMessage(request, [request.bytes]);
}
worker.onmessage = (event: MessageEvent<ConvertReply>) => {
  const reply = event.data;
  if (!reply.ok) {
    status = { label: status.label, line: reply.reason };
    return;
  }
  loader?.dispose();
  loader = new DrftLoader(renderer, { uploadsPerFrame: 4 });
  status = {
    label: status.label,
    line: `${reply.meshes} ${reply.meshes === 1 ? 'MESH' : 'MESHES'} CONVERTED IN ${reply.ms.toFixed(0)} MS`,
  };
  /* The loader streams from a response; a container from a worker is wrapped in one. */
  void loader.consume(new Response(reply.drft), FIT);
};
// #endregion

let status = { label: '', line: '' };

// #region samples
/** Three formats: glTF with its pictures inside it, OBJ with a material beside it, and STL. */
const SAMPLES: Record<string, string> = {
  lantern: 'lantern.glb',
  vase: 'vase.obj',
  gear: 'gear.stl',
};
/* Beside this page: a bundler rewrites `new URL(..., import.meta.url)` for a file, not a folder. */
const folder = new URL('assets/', location.href).href;
async function sample(label: string): Promise<void> {
  const name = SAMPLES[label] ?? 'lantern.glb';
  const response = await fetch(new URL(name, folder));
  open({ name, bytes: await response.arrayBuffer(), folder });
}
/* Any model dropped on the page, and the files dropped with it, such as its `.mtl` or textures. */
addEventListener('dragover', (event) => event.preventDefault());
addEventListener('drop', (event) => {
  event.preventDefault();
  const files = [...(event.dataTransfer?.files ?? [])];
  const model = files.find((file) => readerFor(extensionOf(file.name)) !== undefined);
  if (model === undefined) return;
  void Promise.all(files.map(async (file) => [file.name, await file.arrayBuffer()] as const)).then(
    (read) => {
      const dropped = Object.fromEntries(read);
      const bytes = dropped[model.name] as ArrayBuffer;
      delete dropped[model.name];
      open({ name: model.name, bytes, dropped });
    },
  );
});
// #endregion

const chosen = flag('model', 'lantern');
controls([
  {
    key: 'model',
    label: 'sample',
    value: chosen,
    options: Object.keys(SAMPLES).map((s) => ({ text: s, value: s })),
    change: (value) => void sample(value),
  },
]);
void sample(chosen);

const plinth = renderer.createMesh(
  new MeshBuilder()
    .addCylinder([0, 0.05, 0], 1.3, 0.05, 'y', [0.3, 0.3, 0.32], 0, 48)
    .addBox([0, -0.05, 0], [20, 0.05, 20], [0.16, 0.17, 0.19])
    .build(),
);
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const env = createEnvironment({
  directionalDir: [0.45, 0.8, 0.4],
  directionalColor: [1.6, 1.55, 1.45],
  ambient: [0.36, 0.38, 0.45],
  ambientGround: [0.12, 0.12, 0.13],
  /* Emissive is gated on how much of night there is, so a lantern's flame needs a little of it. */
  nightFactor: 0.5,
  emissiveGain: 1.5,
});
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.7;
env.shadowDepthSpan = computeLightMatrix(
  env.directionalDir,
  0,
  0.8,
  0,
  2.5,
  renderer.shadowMapSize,
  lightMatrix,
);
const readout = createReadout(renderer, 3);
let time = 0;

stage.run({
  simulate(dt) {
    time += dt;
    loader?.update(dt);
  },
  render() {
    camera.fovYDeg = 40;
    camera.position[0] = Math.sin(time * 0.25) * 4.2;
    camera.position[1] = 2.2;
    camera.position[2] = Math.cos(time * 0.25) * 4.2;
    camera.lookAt(0, 0.8, 0);
    const parts = loader?.parts ?? [];
    renderer.beginShadowPass(lightMatrix, 'static');
    renderer.drawShadowCasters((sink) => {
      sink.mesh(plinth, IDENTITY);
      for (const part of parts) sink.mesh(part.mesh, IDENTITY);
    });
    renderer.endShadowPass();
    renderer.beginFrame([0.2, 0.22, 0.26]);
    renderer.bindMeshPass(camera, env);
    renderer.drawMesh(plinth, IDENTITY);
    // #region draw
    /* Each part with the images its material names: colour, the packed occlusion, roughness and
       metal map, normals and emission, each -1 where the file had none. */
    const textures = loader?.textures ?? null;
    const image = (index: number) => (index >= 0 ? (textures?.at(index) ?? null) : null);
    for (const part of parts) {
      renderer.setMaterial({
        albedo: image(part.albedo),
        orm: image(part.orm),
        normal: image(part.normal),
        emissive: image(part.emissive),
        roughnessScale: part.roughnessScale,
        metallicScale: part.metallicScale,
        occlusionStrength: part.occlusionStrength,
      });
      if (part.opacity >= 1) renderer.drawMesh(part.mesh, IDENTITY);
      else renderer.drawTranslucentMesh(part.mesh, IDENTITY, part.opacity);
    }
    renderer.setMaterial(null);
    // #endregion
    const progress = loader?.progress;
    /* A refusal can be long, so the first line carries what fits and the second the rest. */
    const said = `${status.label}: ${status.line}`.toUpperCase();
    const cut = said.length > 64 ? said.lastIndexOf(' ', 64) : said.length;
    readout.set(0, said.slice(0, cut));
    readout.set(
      1,
      said.length > cut
        ? said.slice(cut + 1)
        : progress === undefined
          ? ''
          : `${progress.phase.toUpperCase()}  ${progress.partsDone}/${progress.partsTotal} PARTS  ${progress.imagesDone}/${progress.imagesTotal} IMAGES`,
    );
    readout.set(2, 'DROP A .GLB .GLTF .OBJ .STL .USDZ .3MF .FBX OR .BLEND ON THE PAGE');
    readout.draw(time);
    renderer.endFrame();
  },
});

convert.ts

examples/models/convert.ts
/**
 * A model file in, a `.drft` container out, off the main thread.
 *
 * Reading a source format is not free: a large model takes seconds to parse and weld, which on the
 * page's own thread is a freeze. So the page posts the file's bytes here and gets back one buffer,
 * a container `DrftLoader` streams exactly as it would stream a baked file from a server. The
 * baker does the same steps offline, and for anything shipped it is the better answer: a baked
 * file needs none of this work at load.
 */
import { writeDrft } from '@driftengine/drft';
import type { DrftMaterial } from '@driftengine/drft';
import {
  DEFAULT_COARSE_CELLS,
  assetCandidates,
  basenameOf,
  browserInflate,
  browserInflateRaw,
  buildCoarseLevel,
  describeImage,
  dropDefaultAttributes,
  extensionOf,
  isOutlineWorthWriting,
  orientMeshes,
  prepareFbxInflate,
  prepareZipInflate,
  readModel,
  weldMesh,
} from '@driftengine/assets';

/**
 * What the page sends: a file's name and bytes, and where to find files the model names beside
 * it, an `.obj`'s `.mtl` or a `.gltf`'s pictures: a folder to fetch from, or the other files that
 * were dropped with it. What comes back: a container, or why not.
 */
export interface ConvertRequest {
  readonly name: string;
  readonly bytes: ArrayBuffer;
  readonly folder?: string;
  readonly dropped?: Readonly<Record<string, ArrayBuffer>>;
}
export type ConvertReply =
  | { readonly ok: true; readonly drft: ArrayBuffer; readonly meshes: number; readonly ms: number }
  | { readonly ok: false; readonly reason: string };

/**
 * A compressed `.blend`: gzip, which Blender wrote up to 2.9, is the browser's own; zstd, which it
 * writes from 3.0, is not a browser format, so such a file is refused with what to do instead.
 */
async function unpack(compressed: Uint8Array, codec: 'gzip' | 'zstd'): Promise<Uint8Array> {
  if (codec === 'zstd') {
    throw new Error(
      'this .blend is saved with zstd, which a browser cannot decompress: save it uncompressed, or bake it',
    );
  }
  const stream = new Blob([compressed.slice()])
    .stream()
    .pipeThrough(new DecompressionStream('gzip'));
  return new Uint8Array(await new Response(stream).arrayBuffer());
}

// #region convert
async function convert({ name, bytes, folder, dropped }: ConvertRequest): Promise<ConvertReply> {
  const started = performance.now();
  const file = new Uint8Array(bytes);
  /* A file the model names, by the places it could be: the engine says what to look for. */
  const beside = async (named: string): Promise<Uint8Array | null> => {
    for (const candidate of assetCandidates(named)) {
      const own = dropped?.[basenameOf(candidate)];
      if (own !== undefined) return new Uint8Array(own);
      if (folder === undefined) continue;
      const found = await fetch(new URL(candidate, folder));
      if (found.ok) return new Uint8Array(await found.arrayBuffer());
    }
    return null;
  };
  /* The two zipped or deflated formats need their decompressor ready before the parse. */
  const ext = extensionOf(name);
  const inflate = ext === '.fbx' ? await prepareFbxInflate(bytes, browserInflate) : undefined;
  const inflateRaw = ext === '.3mf' ? await prepareZipInflate(bytes, browserInflateRaw) : undefined;
  const imported = await readModel({
    name,
    bytes: file,
    beside,
    decompress: unpack,
    ...(inflate === undefined ? {} : { inflate }),
    ...(inflateRaw === undefined ? {} : { inflateRaw }),
  });

  /* Y up, one vertex per distinct corner, and no attribute every vertex holds the same value of. */
  const up = imported.declaredUp;
  const oriented = up === undefined ? imported.meshes : orientMeshes(imported.meshes, up);
  const meshes = oriented.map((mesh) => dropDefaultAttributes(weldMesh(mesh)));

  /* The pictures the model carries or names, by what their own bytes say they are. A picture
     that cannot be found is left out, and the surfaces that wear it draw untextured. */
  const textures = [];
  for (const reference of imported.textures ?? []) {
    const image = reference.bytes ?? (await beside(reference.name));
    if (image === null) continue;
    const info = describeImage(image);
    textures.push({
      name: reference.name,
      codec: info.codec,
      width: info.width,
      height: info.height,
      bytes: image,
    });
  }

  /* A coarse outline of the whole model, drawn while the rest arrives, where one is worth it. */
  const outline = buildCoarseLevel(meshes, { cells: DEFAULT_COARSE_CELLS });
  const drft = writeDrft({
    meshes,
    ...(outline !== null && isOutlineWorthWriting(outline, meshes) ? { lods: [outline] } : {}),
    ...(imported.materials === undefined
      ? {}
      : { materials: imported.materials as DrftMaterial[] }),
    ...(textures.length === 0 ? {} : { textures }),
  });
  return { ok: true, drft, meshes: meshes.length, ms: performance.now() - started };
}
// #endregion

self.onmessage = (event: MessageEvent<ConvertRequest>): void => {
  convert(event.data).then(
    (reply) => {
      if (reply.ok) self.postMessage(reply, { transfer: [reply.drft] });
    },
    (error: unknown) => {
      self.postMessage({
        ok: false,
        reason: error instanceof Error ? error.message : String(error),
      });
    },
  );
};