Skip to content

Adding Models and Materials

Picking up from the empty scene in Your First Application, let's put something in it.

A mesh is a primitive plus a material

Visible objects in a scene are Mesh nodes, made of two parts:

  • a primitive — vertex data, defining the shape;
  • a material — defining how the surface responds to light.

Several primitives are built in: BoxShape, SphereShape, PlaneShape, CylinderShape, TorusShape.

js
  // Create scene and light
  const scene = new Scene();
  const light = new DirectionalLight(scene);
  light.lookAt(Vector3.one(), Vector3.zero(), Vector3.axisPY());

  // Create a lambert material
  const material = new LambertMaterial();
  material.albedoColor = new Vector4(1, 0, 0, 1);
  // Create a sphere mesh
  new Mesh(scene, new SphereShape(), material);

  // Create camera
  scene.mainCamera = new PerspectiveCamera(scene, Math.PI / 3, 1, 100);
  scene.mainCamera.lookAt(new Vector3(0, 0, 4), Vector3.zero(), new Vector3(0, 1, 0));
  scene.mainCamera.controller = new OrbitCameraController();

Note the directional light on lines 24–25. Most materials render black with no light in the scene, so add lighting along with your objects.

A new mesh sits at the world origin, so the camera goes to (0, 0, 4) and uses lookAt() to face it. lookAt(eye, target, up) is defined on SceneNode, so cameras and ordinary nodes — including the directional light here — all have it.

Choosing a material

The engine ships many materials. Three are enough to start:

MaterialUse for
UnlitMaterialUnlit; flat-color markers, helpers, effects
LambertMaterialSimple diffuse, cheap; stylized or performance-sensitive scenes
PBRMetallicRoughnessMaterialPhysically based; the default choice for realistic looks

There are also dedicated materials for skin, hair, eyes and cartoon shading (MToon), plus materials authored with editor blueprints. Those can wait until the basics are familiar.

Adding textures

A PBR material needs textures to show its strengths:

js
  // Create a PBR material
  const material = new PBRMetallicRoughnessMaterial();
  // metallic 0.9
  material.metallic = 0.9;
  // roughness 0.6
  material.roughness = 0.6;
  // Load albedo map and normal map
  getEngine()
    .resourceManager.fetchTexture('https://cdn.zephyr3d.org/doc/assets/images/earthcolor.jpg')
    .then((texture) => {
      material.albedoTexture = /** @type {import('@zephyr3d/device').Texture2D} */ (texture);
    });
  getEngine()
    .resourceManager.fetchTexture('https://cdn.zephyr3d.org/doc/assets/images/earthnormal.png', {
      linearColorSpace: true
    })
    .then((texture) => {
      material.normalTexture = /** @type {import('@zephyr3d/device').Texture2D} */ (texture);
    });

Higher metallic looks more like metal; higher roughness spreads the highlight out.

Note that the normal map passes linearColorSpace: true and the color map does not. This is not a stylistic choice: a color map stores sRGB-encoded color that must be converted to linear space for lighting, whereas normal maps, metallic-roughness maps, masks and height maps store data rather than color, and an sRGB conversion gives wrong results. Forgetting it usually shows up as incorrect bump direction and strength.

Texture loading is asynchronous and fetchTexture() returns a promise. Loaded resources are cached, so the same path is not fetched twice.

Loading models

Most objects in a real project come from model files rather than built-in primitives. There are two paths:

  1. Load a prefab (recommended) — import the model in the editor, save it as .zprefab, and load it at runtime with instantiatePrefab().
  2. Load the source model directly — install @zephyr3d/loaders, register an importer, and read glTF/GLB/FBX with fetchModel().

The first is recommended: @zephyr3d/scene contains no model-format parsing code, so prefabs keep those importers out of your bundle. A prefab also stores a serialized engine object graph, so material tweaks, node properties and scripts authored in the editor come back with it. Use the second path when you need to load arbitrary user-supplied models at runtime. See Resource Loading and Model Import.

js
import { HttpFS, Vector3 } from '@zephyr3d/base';
import {
  Scene,
  Application,
  OrbitCameraController,
  PerspectiveCamera,
  DirectionalLight,
  getInput,
  getEngine
} from '@zephyr3d/scene';
import { backendWebGL2 } from '@zephyr3d/backend-webgl';

const myApp = new Application({
  backend: backendWebGL2,
  canvas: document.querySelector('#my-canvas'),
  runtimeOptions: {
    // When using the editor workflow, the asset path must be correctly configured
    VFS: new HttpFS('https://cdn.zephyr3d.org/doc/tut-10')
  }
});

myApp.ready().then(function () {
  // Create scene and light
  const scene = new Scene();
  const light = new DirectionalLight(scene);
  light.lookAt(Vector3.one(), Vector3.zero(), Vector3.axisPY());

  // Load a model
  getEngine()
    .resourceManager.instantiatePrefab(scene.rootNode, '/assets/Duck.zprefab')
    .then((model) => {
      model.position.setXYZ(0, -0.5, 0);
    });

  // Create camera
  scene.mainCamera = new PerspectiveCamera(scene, Math.PI / 3, 1, 100);
  scene.mainCamera.lookAt(new Vector3(0, 0, 3), Vector3.zero(), new Vector3(0, 1, 0));
  scene.mainCamera.controller = new OrbitCameraController();

  getInput().use(scene.mainCamera.handleEvent, scene.mainCamera);

  getEngine().setRenderable(scene, 0);

  myApp.run();
});

Loading editor-produced assets requires a configured VFS (lines 18–21) — the path given to instantiatePrefab() is a VFS path, not a URL, and HttpFS maps it onto an HTTP root here. Other VFS implementations (in-memory, IndexedDB) are covered in Virtual File System.

Organising the hierarchy

Nodes form a tree, and every node's transform is relative to its parent. Move a parent and all of its descendants follow:

javascript
child.parent = parent;
child.position.setXYZ(0, 2, 0);   // 2 units above parent

That mechanism, along with traversal, lookup, visibility and bounding volumes, is covered in Scene Graph and Nodes.

Next

Released under the MIT License.