Is it possible to use custom vertex attributes from GLTF?

Hi everyone,

Is it possible to use custom vertex attributes from GLTF? Blender exporter allows export of custom vertex attributes if they start with underscore _ character.
Godot 4.0 imports these under CUSTOM0…CUSTOM3 semantic/attribute in their shaders
Is this a supported feature in PlayCanvas, if not where should I take a look if I want to try and add the support? (Seems like the glb-parser.js file would be a good start maybe)


I don’t think this is directly supported … see here for what we understand I think - plenty of uv channels are there:

so this is where the data gets copied to a mesh.

To get them accessible in the standard shader, there’s StandardMaterial.setAttribute function to map those.

Thanks, I tried using extra UV channels with data packing, but because the Y component is flipped and maybe clamped to [0…1] I couldn’t get this working always getting wrong result with unpacking.
The reason I need this is for encoding pivots for tree/foliage wind movement, in a similar way Unreal does and the custom attributes are the perfect fit for that.
I’m using a custom shader generator and I can easily add attributes for my shaders by I guess I need some custom semantics there or using SEMANTIC_ATTR0…15, although I noticed that those overlap with the standard ones.
I’ll dig a bit deeper, thanks for the suggestion.

yep those overlap, there’s only 16 attributes accessible at the same time (on most devices), so we don’t expose more than that.

The other way to go around this could be to store data in textures … and use vertex id to sample from it … I use this for morphing for example, but skinning / clustered lighting are similar concepts too.

I considered using textures as well, I may still go that route but I suppose that would add an extra sampling in the shaders and the attributes are faster. The downside is that I get extra data for geometry.

I’m thinking that we could add four SEMANTIC_CUSTOMXX to the engine and thus supporting also custom attribute. These would have a dynamic index but overlap with the 16 standard or the “attr” ones.
If I manage to find an elegant way of adding these I might come with a proposal to integrate in the engine.

1 Like

I’ve come up with a solution that seems to work fine for us.
I think it’s hard to come up with a better one without fundamentally changing the way the semantics are tied to the attribute locations. They’re pretty much static and making them dynamic would require some changes that would break existing code, especially code that relies on the generic SEMANTIC_ATTRx.

So instead I’ve come up with a simple solution that adds four SEMANTIC_CUSTOMx semantics. We could use something like SEMANTIC_GLTFATTRx for naming. I made the attribute locations for these semantics to overlap with the last four texture coordinates semantics

semanticToLocation[SEMANTIC_POSITION] = 0;
semanticToLocation[SEMANTIC_NORMAL] = 1;
semanticToLocation[SEMANTIC_BLENDWEIGHT] = 2;
semanticToLocation[SEMANTIC_BLENDINDICES] = 3;
semanticToLocation[SEMANTIC_COLOR] = 4;
semanticToLocation[SEMANTIC_TEXCOORD0] = 5;
semanticToLocation[SEMANTIC_TEXCOORD1] = 6;
semanticToLocation[SEMANTIC_TEXCOORD2] = 7;
semanticToLocation[SEMANTIC_TEXCOORD3] = 8;
semanticToLocation[SEMANTIC_TEXCOORD4] = 9;
semanticToLocation[SEMANTIC_CUSTOM0] = 9;
semanticToLocation[SEMANTIC_TEXCOORD5] = 10;
semanticToLocation[SEMANTIC_CUSTOM1] = 10;
semanticToLocation[SEMANTIC_TEXCOORD6] = 11;
semanticToLocation[SEMANTIC_CUSTOM2] = 11;
semanticToLocation[SEMANTIC_TEXCOORD7] = 12;
semanticToLocation[SEMANTIC_CUSTOM3] = 12;
semanticToLocation[SEMANTIC_TANGENT] = 13;

and the changes to the glb-parser are quite light, they only affect createVertexBuffer

const createVertexBuffer = (device, attributes, indices, accessors, bufferViews, flipV, vertexBufferDict) => {

    // extract list of attributes to use
    const useAttributes = {};
    const customAttributes = {};
    const attribIds = [];
    let customAttributesCount = 0;

    for (const attrib in attributes) {
        if (attributes.hasOwnProperty(attrib) && (gltfToEngineSemanticMap.hasOwnProperty(attrib) ||
            (attrib.startsWith('_') && (++customAttributesCount) && customAttributesCount < 4))) {
            useAttributes[attrib] = attributes[attrib];
            // build unique id for each attribute in format: Semantic:accessorIndex
            attribIds.push(attrib + ':' + attributes[attrib]);

    customAttributesCount = 0;

    // sort unique ids and create unique vertex buffer ID
    const vbKey = attribIds.join();

    // return already created vertex buffer if identical
    let vb = vertexBufferDict[vbKey];
    if (!vb) {
        // build vertex buffer format desc and source
        const sourceDesc = {};
        for (const attrib in useAttributes) {
            const accessor = accessors[attributes[attrib]];
            const accessorData = getAccessorData(accessor, bufferViews);
            const bufferView = bufferViews[accessor.bufferView];
            let semantic = SEMANTIC_POSITION;
            if (gltfToEngineSemanticMap.hasOwnProperty(attrib)) {
                semantic = gltfToEngineSemanticMap[attrib];
            } else {
                semantic = SEMANTIC_CUSTOM + customAttributesCount;
                customAttributes[attrib] = semantic;
            const size = getNumComponents(accessor.type) * getComponentSizeInBytes(accessor.componentType);
            const stride = bufferView && bufferView.hasOwnProperty('byteStride') ? bufferView.byteStride : size;
            sourceDesc[semantic] = {
                buffer: accessorData.buffer,
                size: size,
                offset: accessorData.byteOffset,
                stride: stride,
                count: accessor.count,
                components: getNumComponents(accessor.type),
                type: getComponentType(accessor.componentType),
                normalize: accessor.normalized

        // generate normals if they're missing (this should probably be a user option)
        if (!sourceDesc.hasOwnProperty(SEMANTIC_NORMAL)) {
            generateNormals(sourceDesc, indices);

        // create and store it in the dictionary
        vb = createVertexBufferInternal(device, sourceDesc, flipV);
        vertexBufferDict[vbKey] = vb;

    return vb;

and the attribute order used for sorting

// order vertexDesc to match the rest of the engine
const attributeOrder = {

Then we can easily use the custom semantics afterwards in our material system

export class SimpleTreeMaterial extends CustomMaterial {
  static materialType = `SimpleTreeMaterial`;
  static shaderGenerator = new CustomShaderGenerator(
      aPosition: pc.SEMANTIC_POSITION,
      aNormal: pc.SEMANTIC_NORMAL,
      aColor: pc.SEMANTIC_COLOR,
      aPivot0: pc.SEMANTIC_CUSTOM0,
      aPivot1: pc.SEMANTIC_CUSTOM1,
      Forward: { vshader: simpleTreeVS, fshader: simpleTreePS },

It has the downside that you can get overlapping attribute locations if you have more than four UV layers in your meshes but that is easily avoided since we define what attributes we have in our GLTF files. You can either have up to 8 UV layers or 4 of them and 4 custom vertex attributes to use however you like.

1 Like