Skip to content

Custom Descriptor

In navara_three, you can implement your own mesh, effect, and light descriptors. For an overview of the layer concept, see Layers & Descriptors.

Depending on the descriptor type, you inherit from the corresponding base class to implement your Descriptor.

Descriptor TypeBase ClassFactory MethodRegistration Method
MeshMeshDesccreateMesh()view.registerMesh()
Instanced MeshInstancedMeshDesccreateGeometry() + createMaterial()view.registerMesh()
EffectEffectDesccreatePass()view.registerEffect()
LightLightDesccreateLight()view.registerLight()

All base classes inherit from BaseDesc and share a common lifecycle.

MethodTimingDescription
constructor(view, ctx, config)When the Descriptor is createdReceives the ThreeView, ViewContext, and configuration
onCreate()When addMesh()/addLight()/addEffect() is calledCalls the factory method to create an instance and adds it to the scene. Implemented by the base class
onUpdateConfig(updates)When handle.update() is calledProcesses partial configuration updates
onDestroy()When handle.delete() is calledReleases resources and removes from the scene
update(time)Every frame (optional)Animation processing. Only called if implemented
onResize(width, height)On viewport resize (optional)Mesh Descriptors only. Only called if implemented
PropertyTypeDescription
viewThreeViewThe ThreeView instance providing access to camera, atmosphere, globe, and other view state
ctxViewContextThe view context providing access to scenes, passes, and rendering internals
_instanceInstance | undefinedThe created Three.js object
idstringUnique identifier of the object
visiblebooleanShow/hide

Custom descriptors access internal APIs through two properties: this.view and this.ctx.

  • this.view (ThreeView) — High-level view state: camera, atmosphere, globe
  • this.ctx (ViewContext) — Rendering internals: scenes, post-processing passes, buffers, textures

See ThreeView Properties.

PropertyDescription
ctx.scenes.opaqueScene for opaque objects
ctx.scenes.transparentScene for transparent objects
ctx.scenes.mrtScene for selective effects (Bloom / Outline)
ctx.scenes.skyEnvMapScene for environment maps
ctx.scenes.lightScene for lights
ctx.scenes.drapedScene for terrain-draped meshes
MethodDescription
ctx.getPass(name)Get a post-processing pass by name
ctx.addPass(name, pass)Add a post-processing pass
ctx.insertPassBefore(targetName, name, pass)Insert a pass before the target pass
ctx.insertPassAfter(targetName, name, pass)Insert a pass after the target pass
ctx.removePass(name)Remove a post-processing pass by name
MethodDescription
ctx.getRenderer()Get the WebGLRenderer instance
ctx.getInputBuffer()Get the input buffer from the effect composer
MethodDescription
ctx.getRenderTarget()Get the main render target (includes G-buffer)
ctx.getGlobeDepthTexture()Get the globe depth texture for post-processing
ctx.getGlobeNormalTexture()Get the globe normal texture for post-processing
ctx.getNormalTexture()Get the scene normal texture from the G-buffer
ctx.getEffectIdsTexture()Get the effect IDs texture from the G-buffer
ctx.getEmissiveTexture()Get the emissive texture from the G-buffer
MethodDescription
ctx.applyShadowMaterial(material)Apply CSM shadows to a material
ctx.removeShadowMaterial(material)Remove CSM shadows from a material

Navara renders into a multiple-render-target (MRT) G-buffer. Beyond color, every material also writes a view-space normal, an effect ID bitmask, and an emissive buffer. Depth/normal-based effects (SSAO, SSR, outlines, aerial perspective, clouds) and selective effects (Bloom / Outline) read these attachments, so a mesh participates in those effects only if its material writes the G-buffer.

AttachmentLocationContents
Color0gl_FragColor
Normal1View-space normal (plus material properties)
Effect ID2Selective-effect bitmask
Emissive3Selective-effect additive emissive

Importing @navaramap/three patches every built-in Three.js material — MeshStandardMaterial, MeshBasicMaterial, MeshLambertMaterial, MeshPhongMaterial, SpriteMaterial, PointsMaterial — so they write the G-buffer. When your custom Descriptor uses one of these, there is nothing to do.

ShaderMaterial and three-stdlib LineMaterial do not go through Three.js ShaderLib, so Navara cannot patch them automatically. Opt them in with setupMaterialForMRT().

import { setupMaterialForMRT } from "@navaramap/three";
const material = new ShaderMaterial({ uniforms, vertexShader, fragmentShader });
// Name the view-space normal variable in your fragment shader (default "normal").
setupMaterialForMRT(material, { normal: "vNormal" });
// LineMaterial is detected and routed automatically; `normal` is ignored for it.
setupMaterialForMRT(lineMaterial);
ParameterTypeDescription
materialShaderMaterialThe custom material to patch. LineMaterial (which extends ShaderMaterial) is detected and handled automatically
options.normalstringName of the view-space normal variable in the fragment shader. Defaults to "normal". It is packed with packNormalToVec2, which assumes view space

If you skip this, the mesh writes nothing to the normal / effect-ID / emissive attachments, so depth/normal-based effects and selective effects break on that mesh (garbage normals, no bloom or outline).

Requirements and behavior:

  • The fragment shader must expose a view-space normal named by options.normal.
  • The last } in the vertex and fragment shaders is assumed to close main().
  • Requires WebGL2 / GLSL 3 (Navara’s default).
  • Idempotent — calling it again on the same material is a no-op.

The automatic built-in patching is performed by an internal overrideMaterialsForMRT() on import; application code never calls it.

To read these buffers from a custom effect, use the Buffer / Texture Access accessors on ctx, or reference the MRT pass from another effect via find<MRTPassEffectDesc>("mrt").

class MyMeshDesc extends MeshDesc<
Config, // Descriptor configuration type (extends MeshConfig)
UpdateConfig, // Update configuration type (extends MeshUpdate)
InstanceObj, // Three.js object type (extends Object3D)
> {}
import type { MeshConfig, MeshUpdate } from "@navaramap/three";
type MyMeshDescription = {
myMesh?: {
radius?: number;
color?: Color;
};
};
type MyMeshConfig = MeshConfig & MyMeshDescription;
type MyMeshUpdate = MeshUpdate & MyMeshDescription;

MeshDesc automatically handles position, rotation, scale, matrix, matrixWorld, pickable, and visible. For a full description of these properties, transform composition modes, and picking behavior, see MeshDesc.

You can override getPassKey() to change the scene where the mesh is rendered.

PassKeyDescription
"opaque"Opaque rendering (default)
"transparent"Transparent rendering
"mrt"For selective effects (Bloom / Outline)
"skyEnvMap"For environment maps
"draped"For terrain-draped rendering
import ThreeView, {
MeshDesc,
type MeshConfig,
type MeshUpdate,
type ViewContext,
Color,
} from "@navaramap/three";
import {
Mesh,
SphereGeometry,
MeshStandardMaterial,
} from "three";
// Define configuration types
type MySphereMeshDescription = {
mySphere?: {
radius?: number;
color?: Color;
castShadow?: boolean;
};
};
type MySphereMeshConfig = MeshConfig & MySphereMeshDescription;
type MySphereMeshUpdate = MeshUpdate & MySphereMeshDescription;
export class MySphereMeshDesc extends MeshDesc<
MySphereMeshConfig,
MySphereMeshUpdate,
Mesh<SphereGeometry, MeshStandardMaterial>
> {
private config: MySphereMeshConfig;
constructor(view: ThreeView, ctx: ViewContext, config: MySphereMeshConfig) {
super(view, ctx, config);
this.config = config;
}
// Create and return the Three.js object
createMesh() {
const cfg = this.config.mySphere ?? {};
const geometry = new SphereGeometry(cfg.radius ?? 1);
const material = new MeshStandardMaterial({
color: cfg.color?.raw ?? 0xffffff,
});
const mesh = new Mesh(geometry, material);
// Enable shadows if configured
if (cfg.castShadow) {
mesh.castShadow = true;
this.ctx.applyShadowMaterial(material);
}
return mesh;
}
// Handle partial updates
onUpdateConfig(updates: MySphereMeshUpdate) {
if (updates.mySphere && this._instance) {
if (updates.mySphere.radius !== undefined) {
// Call recreate() when geometry needs to be recreated
this.recreate();
}
if (updates.mySphere.color !== undefined) {
this._instance.material.color.set(updates.mySphere.color.raw);
}
this.emit("needsUpdate");
}
// Base class handling (position, scale, rotation, visible)
super.onUpdateConfig(updates);
}
// Release resources
onDestroy() {
if (this._instance) {
this._instance.geometry.dispose();
this._instance.material.dispose();
}
super.onDestroy();
}
}
import ThreeView from "@navaramap/three";
const view = new ThreeView({});
view.registerMesh("mySphere", MySphereMeshDesc);
await view.init();
const handle = view.addMesh<MySphereMeshDesc>({
mySphere: { radius: 100, color: new Color().setHex(0x00aaff) },
position: { x: 0, y: 0, z: 6378137 },
});
// Partial update
handle.update({ mySphere: { color: new Color().setHex(0xff0000) } });

Implementing the update() method causes it to be called every frame.

export class RotatingBoxDesc extends MeshDesc</* ... */> {
createMesh() {
// ...
}
// Called every frame
update(time: number) {
if (this._instance) {
this._instance.rotation.y = time * 0.001;
}
}
}

For rendering many copies of the same geometry in a single draw call, use InstancedMeshDesc. All instances share one geometry and material, with per-instance variation through instanceMatrix and instanceColor.

class MyInstancedDesc extends InstancedMeshDesc<
TGeometry, // Three.js BufferGeometry type
TMaterial, // Three.js Material type
Config, // Descriptor configuration type (extends InstancedMeshConfig)
UpdateConfig, // Update configuration type (extends InstancedMeshUpdate)
ChildConfig, // Per-instance configuration type (extends InstancedChildConfig)
> {}

Common transform fields for individual instances:

PropertyTypeDescription
positionXYZLocal position relative to the parent group
rotationXYZLocal rotation (Euler angles in radians)
scaleXYZLocal scale
matrixMatrix4Pre-computed transform matrix. When set, position/rotation/scale are ignored
MethodReturn TypeDescription
createGeometry()TGeometryCreate the shared geometry for all instances
createMaterial()TMaterialCreate the shared material for all instances
getChildConfigs()ChildConfig[]Extract the initial array of instance configs from the Descriptor config
getInstanceColor(config)ThreeColor | undefinedExtract the per-instance color, or undefined for default white
MethodDescription
getInstanceScale(config, target)Compute per-instance scale. Override to incorporate geometry-specific dimensions (e.g., width/height/depth)
composeInstanceMatrix(config)Compose the transform matrix for one instance. Override for custom transform logic
MethodSignatureDescription
add(config)(config: ChildConfig) => numberAdd a new instance. Returns the index
removeAt(index)(index: number) => voidRemove by index (swap-with-last, O(1))
updateAt(index, config)(index: number, config: Partial<ChildConfig>) => voidUpdate an instance at the given index
clear()() => voidRemove all instances
replaceAll(configs)(configs: ChildConfig[]) => voidBatch replace all instances (single update)
countnumber (getter)Number of active instances
import ThreeView, {
InstancedMeshDesc,
type InstancedMeshConfig,
type InstancedMeshUpdate,
type InstancedChildConfig,
type ViewContext,
Color,
} from "@navaramap/three";
import {
BoxGeometry,
MeshStandardMaterial,
Color as ThreeColor,
} from "three";
// Per-instance configuration
type MyBoxChild = InstancedChildConfig & {
color?: Color;
};
// Descriptor configuration
type MyBoxesConfig = InstancedMeshConfig & {
boxes?: { children?: MyBoxChild[] };
};
type MyBoxesUpdate = InstancedMeshUpdate & {
boxes?: { children?: MyBoxChild[] };
};
export class MyBoxesDesc extends InstancedMeshDesc<
BoxGeometry,
MeshStandardMaterial,
MyBoxesConfig,
MyBoxesUpdate,
MyBoxChild
> {
private config: MyBoxesConfig;
constructor(view: ThreeView, ctx: ViewContext, config: MyBoxesConfig) {
super(view, ctx, config);
this.config = config;
}
createGeometry() {
return new BoxGeometry(1, 1, 1);
}
createMaterial() {
return new MeshStandardMaterial();
}
getChildConfigs(): MyBoxChild[] {
return this.config.boxes?.children ?? [];
}
getInstanceColor(config: MyBoxChild): ThreeColor | undefined {
return config.color ? new ThreeColor(config.color.raw) : undefined;
}
}
import ThreeView, { Color } from "@navaramap/three";
const view = new ThreeView({});
view.registerMesh("myBoxes", MyBoxesDesc);
await view.init();
const handle = view.addMesh<MyBoxesDesc>({
boxes: {
children: [
{ position: { x: 0, y: 0, z: 100 }, color: new Color().setHex(0xff0000) },
{ position: { x: 200, y: 0, z: 100 }, color: new Color().setHex(0x00ff00) },
],
},
position: { x: 0, y: 0, z: 6378137 },
});
// Add an instance dynamically
handle.ref.add({ position: { x: 400, y: 0, z: 100 }, color: new Color().setHex(0x0000ff) });
// Update instance at index 0
handle.ref.updateAt(0, { color: new Color().setHex(0xffff00) });
// Remove instance at index 1
handle.ref.removeAt(1);
class MyEffectDesc extends EffectDesc<
Config, // Descriptor configuration type (extends EffectConfig)
UpdateConfig, // Update configuration type (extends EffectUpdate)
InstanceObj, // Post-processing pass type
> {}

Effect descriptors have static properties that control their insertion position within the render pipeline.

PropertyTypeDescription
keystringRequired. Unique key name for the effect
insertAfterstring[]Insert after the specified effects (preferred)
insertBeforestring[]Insert before the specified effects (fallback if insertAfter targets are not found)
allowDuplicationbooleanWhether to allow multiple instances of the same effect

The insertion order is determined by priority: insertAfter -> insertBefore -> append to end.

import ThreeView, {
EffectDesc,
type EffectConfig,
type EffectUpdate,
type ViewContext,
} from "@navaramap/three";
type MyEffectDescription = {
myEffect?: {
intensity?: number;
};
};
type MyEffectConfig = EffectConfig & MyEffectDescription;
type MyEffectUpdate = EffectUpdate & MyEffectDescription;
export class MyEffectDesc extends EffectDesc<
MyEffectConfig,
MyEffectUpdate,
MyPostProcessingPass
> {
// Control ordering within the pipeline
static key = "myEffect";
static insertAfter = ["clouds"];
static insertBefore = ["transparent"];
private config: MyEffectConfig;
constructor(view: ThreeView, ctx: ViewContext, config: MyEffectConfig) {
super(view, ctx, config);
this.config = config;
}
// Create and return the post-processing pass
createPass() {
const cfg = this.config.myEffect ?? {};
return new MyPostProcessingPass({
intensity: cfg.intensity ?? 1.0,
});
}
onUpdateConfig(updates: MyEffectUpdate) {
if (updates.myEffect && this._instance) {
if (updates.myEffect.intensity !== undefined) {
this._instance.intensity = updates.myEffect.intensity;
}
this.emit("needsUpdate");
}
super.onUpdateConfig(updates);
}
}

You can reference other registered effect descriptors using find().

createPass() {
const mrt = this.find<MRTPassEffectDesc>("mrt");
// ...
}
class MyLightDesc extends LightDesc<
Config, // Descriptor configuration type (extends LightConfig)
UpdateConfig, // Update configuration type (extends LightUpdate)
InstanceObj, // Three.js Light type
> {}
PropertyTypeDescription
position{ x, y, z }Light position
visiblebooleanShow/hide

Lights are automatically added to the ctx.scenes.light scene.

import ThreeView, {
LightDesc,
type LightConfig,
type LightUpdate,
type ViewContext,
Color,
} from "@navaramap/three";
import { PointLight } from "three";
type MyPointLightDescription = {
myPointLight?: {
color?: Color;
intensity?: number;
distance?: number;
};
};
type MyPointLightConfig = LightConfig & MyPointLightDescription;
type MyPointLightUpdate = LightUpdate & MyPointLightDescription;
export class MyPointLightDesc extends LightDesc<
MyPointLightConfig,
MyPointLightUpdate,
PointLight
> {
private config: MyPointLightConfig;
constructor(view: ThreeView, ctx: ViewContext, config: MyPointLightConfig) {
super(view, ctx, config);
this.config = config;
}
createLight() {
const cfg = this.config.myPointLight ?? {};
const light = new PointLight(
cfg.color?.raw ?? 0xffffff,
cfg.intensity ?? 1,
cfg.distance ?? 0,
);
return light;
}
onUpdateConfig(updates: MyPointLightUpdate) {
if (updates.myPointLight && this._instance) {
if (updates.myPointLight.color !== undefined) {
this._instance.color.set(updates.myPointLight.color.raw);
}
if (updates.myPointLight.intensity !== undefined) {
this._instance.intensity = updates.myPointLight.intensity;
}
if (updates.myPointLight.distance !== undefined) {
this._instance.distance = updates.myPointLight.distance;
}
this.emit("needsUpdate");
}
super.onUpdateConfig(updates);
}
}

The BaseHandle<T> returned from view.addMesh(), view.addLight(), or view.addEffect() is a handle for controlling the object.

Property / MethodTypeDescription
idstringUnique identifier of the object
visiblebooleanGet/set show/hide
refTDirect access to the base Descriptor instance
update(updates)voidPartial configuration update
delete()voidDelete the object. Calls onDestroy()

Implementing Picking in Custom Descriptors

Section titled “Implementing Picking in Custom Descriptors”

For an overview of picking from the user’s perspective, see MeshDesc — Picking. This section covers how to implement picking support when authoring a custom Descriptor.

For Descriptors that use standard Three.js materials (MeshStandardMaterial, MeshLambertMaterial, etc.) or ShaderMaterial, wrap the mesh in a PickableMeshWrapper. It automatically injects the picking shader code.

import ThreeView, {
MeshDesc,
PickableMeshWrapper,
type MeshConfig,
type MeshUpdate,
type ViewContext,
Color,
} from "@navaramap/three";
import { Mesh, BoxGeometry, MeshStandardMaterial } from "three";
type MyConfig = MeshConfig & { myBox?: { color?: Color } };
type MyUpdate = MeshUpdate & { myBox?: { color?: Color } };
class MyPickableBoxDesc extends MeshDesc<
MyConfig, MyUpdate, Mesh
> {
private config: MyConfig;
private pickWrapper?: PickableMeshWrapper;
constructor(view: ThreeView, ctx: ViewContext, config: MyConfig) {
super(view, ctx, config);
this.config = config;
}
get batchId(): number | undefined {
return this.pickWrapper?.batchId;
}
createMesh() {
const mesh = new Mesh(
new BoxGeometry(1, 1, 1),
new MeshStandardMaterial({ color: this.config.myBox?.color?.raw ?? 0xffffff }),
);
if (this.config.pickable) {
this.pickWrapper = new PickableMeshWrapper(mesh, this.ctx);
this.ctx.registerPickableMesh(this.id, this.pickWrapper);
}
return mesh;
}
onDestroy() {
if (this.pickWrapper) {
this.ctx.unregisterPickableMesh(this.id);
}
super.onDestroy();
}
}

For instanced mesh Descriptors, use PickableInstancedMeshWrapper. It assigns a unique batch ID per instance, enabling you to identify which individual instance was clicked.

import {
InstancedMeshDesc,
PickableInstancedMeshWrapper,
} from "@navaramap/three";
class MyPickableInstancedDesc extends InstancedMeshDesc</* ... */> {
private pickWrapper?: PickableInstancedMeshWrapper;
get batchIds(): readonly number[] {
return this.pickWrapper?.batchIds ?? [];
}
override onCreate() {
super.onCreate();
if (this.config.pickable) {
this.pickWrapper = new PickableInstancedMeshWrapper(
this.raw, this.count, this.ctx,
);
this.ctx.registerPickableMesh(this.id, this.pickWrapper);
}
}
protected override onInstanceAdded(index: number) {
this.pickWrapper?.addInstance();
}
protected override onInstanceRemoved(index: number, wasLast: boolean) {
this.pickWrapper?.removeInstanceAt(index);
}
protected override onInstanceMeshReplaced(newMesh: InstancedMesh) {
this.pickWrapper?.syncMesh(newMesh);
}
onDestroy() {
if (this.pickWrapper) {
this.ctx.unregisterPickableMesh(this.id);
}
super.onDestroy();
}
}

For Descriptors with fully custom shaders, implement the PickableMesh interface directly. Your fragment shader must encode the batch ID as an RGB color when the picking uniform is active.

import { type PickableMesh } from "@navaramap/three";
class CustomPickable extends Object3D implements PickableMesh {
batchId: number;
private mesh: Mesh;
constructor(mesh: Mesh, ctx: ViewContext) {
super();
this.mesh = mesh;
this.batchId = ctx.genGlobalBatchId() ?? 0;
mesh.material.uniforms.uBatchId.value = this.batchId;
}
onBeforePicking() {
this.mesh.material.uniforms.uPicking.value = 1;
}
onAfterPicking() {
this.mesh.material.uniforms.uPicking.value = 0;
}
getRenderable() {
return this.mesh;
}
}

The batch ID encoding in the fragment shader:

vec3 batchIdToColor(float id) {
float r = floor(id / 65536.0);
float g = floor(mod(id / 256.0, 256.0));
float b = mod(id, 256.0);
return vec3(r, g, b) / 255.0;
}
// In the main function:
if (uPicking > 0.0) {
gl_FragColor = vec4(batchIdToColor(uBatchId), 1.0);
return;
}
MethodDescription
ctx.genGlobalBatchId()Generate a unique batch ID for picking
ctx.registerPickableMesh(key, mesh)Register a pickable mesh
ctx.unregisterPickableMesh(key)Unregister a pickable mesh