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.findEffect(key)First active effect Descriptor registered under key (e.g. "mrt")
ctx.findLight(key)First active light Descriptor registered under key (e.g. "sun")
ctx.findMesh(key)First active mesh Descriptor registered under key (e.g. "gltfModel")

Use these to inherit the scene’s existing configuration instead of duplicating it: a custom lighting effect can read the sun’s intensity and colour from ctx.findLight("sun") rather than taking its own options. Each returns undefined when no such Descriptor is active.

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 terrain-only normal, sampled in screen space. Declare globeNormal in requiredBuffers. It is kept at 1x1 otherwise
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
ctx.getShadowTexture()Get the shadow texture (R=shadow amount, 0=lit..1=fully shadowed) 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. 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.

Only color and normal are always present. The rest are allocated on demand and packed after them with no gaps, so their locations shift with the configuration. The shader receives each one’s layout(location = …) as a stamped define.

AttachmentLocationContentsAllocated
Color0gl_FragColoralways
Normal1View-space normal (plus material properties)always
Effect IDpackedSelective-effect bitmaskbuffers.selectiveEffect
EmissivepackedSelective-effect additive emissivebuffers.emissive
ShadowpackedR = shadow amount (0 = lit .. 1 = shadowed), G = albedo flagbuffers.shadow

Never hardcode an optional attachment index. Read it from the ViewContext accessors below.

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.

Two things are required, and missing either one silently produces a mesh that takes part in no depth/normal-based effect:

  1. setupMaterialForMRT(), so the material writes the attachments.
  2. getPassKey() returning "mrt", so the mesh renders in the G-buffer pass. The default is "opaque", which is composited after the G-buffer copy — a mesh left there writes nothing, whatever its material does.
import ThreeView, {
MeshDesc,
setupMaterialForMRT,
type MeshConfig,
type MeshUpdate,
type PassKey,
type ViewContext,
} from "@navaramap/three";
import { Mesh, ShaderMaterial, SphereGeometry, Uniform, Vector3 } from "three";
type Description = { glowSphere?: { radius?: number } };
type GlowSphereConfig = MeshConfig & Description;
type GlowSphereUpdate = MeshUpdate & Description;
class GlowSphereDesc extends MeshDesc<
GlowSphereConfig,
GlowSphereUpdate,
Mesh<SphereGeometry, ShaderMaterial>
> {
private config: GlowSphereConfig;
constructor(view: ThreeView, ctx: ViewContext, config: GlowSphereConfig) {
super(view, ctx, config);
this.config = config;
}
protected override getPassKey(): PassKey {
return "mrt";
}
createMesh() {
const material = new ShaderMaterial({
uniforms: { uColor: new Uniform(new Vector3(0.1, 0.8, 0.5)) },
vertexShader: `
varying vec3 vNormal;
void main() {
// View space — packNormalToVec2 assumes it.
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 uColor;
varying vec3 vNormal;
void main() {
gl_FragColor = vec4(uColor, 1.0);
}
`,
});
setupMaterialForMRT(material, { normal: "vNormal" });
const radius = this.config.glowSphere?.radius ?? 100;
return new Mesh(new SphereGeometry(radius, 32, 32), material);
}
}
const view = new ThreeView();
view.registerMesh("glowSphere", GlowSphereDesc);
await view.init();
view.addMesh<GlowSphereDesc>({
glowSphere: { radius: 200 },
position: { x: 0, y: 0, z: 6_400_000 },
});

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").

The effectIds, emissive, shadow and globeNormal buffers are optional: they exist only while an active effect declares them in its static requiredBuffers (e.g. ["selectiveEffect", "emissive"], ["shadow"] or ["globeNormal"]), and the accessors return undefined otherwise.

globeNormal is the odd one out: it is a separate screen-space copy of the terrain normal rather than a G-buffer attachment, so it takes no attachment slot and does not count against the device’s MAX_DRAW_BUFFERS. Undeclared, its target stays 1x1 and ctx.getGlobeNormalTexture() returns a texture you cannot sample meaningfully. A custom effect that reads them must declare requiredBuffers so the view allocates them. Note that changing the set of allocated buffers reallocates attachments and recompiles shaders, so effects should be added once and tuned via update() rather than added and removed repeatedly. Because a configuration change rebuilds the attachments, fetch these textures each frame (in update() or the pass’s render()) instead of caching them at pass creation.

Buffer encodings to be aware of when sampling:

  • The normal buffer’s RG channels hold the view-space normal in octahedral encoding. Decode with unpackVec2ToNormal() from the NORMAL_PACKING_SHADER GLSL string exported by @navaramap/three (a plain xy * 2 - 1 reconstruction produces wrong shading).
  • The shadow buffer holds R = shadow amount (0 = lit .. 1 = fully shadowed) and G = albedo-output flag (1 when the fragment’s color is plain albedo via the lit option. A deferred lighting pass uses it as its “shade this pixel” mask).
  • Depth textures follow Three.js packing conventions: check depthBufferPacking / globeDepthBufferPacking on the MRT pass and unpack with the helpers in the DEPTH_PACKING_SHADER GLSL string exported by @navaramap/three (Three.js’s packing chunk).
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, geodetic, pickable, and visible, so a custom descriptor can be placed at a longitude / latitude with geodetic like any built-in one. 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