Skip to main content

Custom Nodes Walkthrough

Node-Diggity, No Doubt

In AniGraph, each node of the scene graph consists of a model and a view, and either or both can be customized. By now you have seen a few examples of how this works, but let's walk through the general structure to get a better sense of how these examples tie together.

Pairing a Model and View:

A node is specified by pairing a model class with a corresponding view class. This is done by adding a model view spec to the scene controller.

Customizing Models and Views:

One way to extend AniGraph is by subclassing existing node model and view classes. You can find many examples of this in the main/Nodes directory of the starter code.

NodeModel3D

Most of the nodes in AniGraph have model classes that are a descendant of the ANodeModel3D class. Some important features of this class are:

  • A transform:NodeTransform3D property that will trigger updates in connected views whenever it is changed.
  • A material:AMaterial property that represents the material used to render the model. In your code, this material will almost always be an instance of AShaderMaterial.
  • A verts:VertexArray3D property that can optionally be used to represent geometry
  • A geometry:AGeometrySet, which holds the verts property, as well as any loaded geometry in the case of loaded objects.

Models are designed to make it easy to synchronize these properties with a view. Whenever model.transform or model.visible changes, this will cause its connected views to update. Whenever model.material is changed, view.onMaterialChange() is called. Whenever model.material is updated---e.g., when the value of one of its uniforms is changed---the view.onMaterialUpdate() function will be called on any connected views.

Each view instance is connected to a specific model. Views also have a view.threejs property that represents a ThreeJS object---in most cases a THREE.Group. Views also have a dictionary of AGraphicObject's. By default, changing the material of a model will assign those changes to all graphics belonging to the corresponding view. You can change this behavior by overwriting the following functions from ANodeView:

onMaterialUpdate(...args:any[]){
const self = this;
this.mapOverGraphics((element:AGraphicObject)=>{
element.onMaterialUpdate(self.model.material, ...args);
})
}

onMaterialChange(){
const self = this;
this.mapOverGraphics((element:AGraphicObject)=>{
element.onMaterialChange(self.model.material);
})
}

Creating new custom materials

The example code creates shader materials in a few different places. For example, the CharacterModel class has its own static instance of CharacterShaderModel that it uses. But let's take a look at a more general version of creating and using a custom shader to help understand all the different parts.

To create a new material with custom shaders, you first need to create a shader model, which will load the shader source and compile it into a shader program. We can see an example of how this is done in Example3SceneModel:

let basicshader1ShaderMaterialModel = await ABasicShaderModel.CreateModel("customexample1");

Here, we are creating a model that subclasses ABasicShaderModel, and we are telling it to use the shader code defined in public/shaders/customexample1/.

The next line in Example3SceneModel is:

await this.materials.setMaterialModel(MyMaterialNames.basicshader1, basicshader1ShaderMaterialModel);

This line simply saves the newly created shader model in the scene's material dictionary under the name specified by the string MyMaterialNames.basicshader1 so we can retrieve it by that name later. This step is not required if you store the shader as its own names variable in some appropriate scope.

To create a shader material using the shaders loaded in our shader model, we use the shaderModel.CreateMaterial(...args) function. In Example3, we assign the sceneModel.playerMaterial property with the line:

this.playerMaterial = this.materials.CreateShaderMaterial(MyMaterialNames.basicshader1);

If you look at the definition of this.materials.CreateShaderMaterial in AMaterialManager you will will see that this line simply retrieves the model we created before and calls CreateMaterial(...args) on it:

// in AMaterialManager, which is the class of sceneModel.materials
CreateShaderMaterial(modelName:string, ...args:any[]){
return this.getMaterialModel(modelName).CreateMaterial(...args) as AShaderMaterial;
}

You can then set the material of a node model by either feeding it as an argument to the model's constructor or using model.setMaterial(mymaterial).

You can set uniforms and textures on the material using material.setUniform(name:string, value:any) and material.setTexture(name:string, texture:ATexture). For example, we could set a uniform named myUniformFloat by calling mat.setUniform('myUniformFloat', 2.5); and declaring uniform float myUniformFloat; in our shader. If we feed setUniform a Vec2, Vec3, Vec4, or Color, it will show up in our shader as the corresponding vector type. We can see how this works by looking at the setUniform definition:

setUniform(name:string, value:any, type?:string) {
if(value instanceof Vec3){
this.setUniform(name, value.asThreeJS(), 'vec3');
return;
}
if(value instanceof Vec4){
this.setUniform(name, value.asThreeJS(), 'vec4');
return;
}
if(value instanceof Vec2){
this.setUniform(name, new THREE.Vector2(value.x, value.y), 'vec2');
return;
}
if(value instanceof Color){
this.setUniform(name, value.Vec4, 'vec4');
return;
}
...

Where do positions, normals, and texture coordinates come from?

Threejs can seem a bit opaque in how the input to shader is specified. Vertex position, normal, color, and texture coordinate attributes actually come from whatever geometry is being rendered. These attributes show up in the vertex shader as the variables. For more on default attributes in threejs shaders, check out this documentation.