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.
// 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:
| Material | Use for |
|---|---|
UnlitMaterial | Unlit; flat-color markers, helpers, effects |
LambertMaterial | Simple diffuse, cheap; stylized or performance-sensitive scenes |
PBRMetallicRoughnessMaterial | Physically 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:
// 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:
- Load a prefab (recommended) — import the model in the editor, save it as
.zprefab, and load it at runtime withinstantiatePrefab(). - Load the source model directly — install
@zephyr3d/loaders, register an importer, and read glTF/GLB/FBX withfetchModel().
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.
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:
child.parent = parent;
child.position.setXYZ(0, 2, 0); // 2 units above parentThat mechanism, along with traversal, lookup, visibility and bounding volumes, is covered in Scene Graph and Nodes.
Next
- Shadows and Post-processing — making it look presentable