www.xbdev.net
xbdev - software development
Wednesday February 5, 2025
Home | Contact | Support | glTF File Format The JPG of 3D Formats ...
     
 

glTF File Format Tutorials

Unlocking the power ...

 


Skinned Bee


There are different ways of doing animations - the simplest is to have each mesh associated with a transform (matrix) - you update the transform and the mesh moves around (scaled, rotated and translated). However, this makes it difficult to create soft body effets - with smooth surfaces.

Skinned animations is a mixed of rigid skeletons and a mesh surface - similar to the human body - we have a skeleton under our body - as the skeleton moves so the skin on the surface. The animation principle for skinning is very similar - we create a rigid skeleton (called nodes) - each of which have a transform - we animate these using animation frames.

The Bee in the image below shows a single mesh which is getting animated by 112 bones - the different colors indicated the areas of the mesh which are influenced by different bones.


Skinned Bee model, the colors help show the bone/weight influence areas for the mesh.
Skinned Bee model, the colors help show the bone/weight influence areas for the mesh.


The `skin` is a mesh which `links` to the skeleton. However, instead of a single transform for each mesh, we have a transform for each vertex! Not just one transform, but typically 4. We average between the multiple transforms to create `smooth` deformations (when two vertices next to each other are influenced by different transforms they don't just snap or change sharply).

The model data for this tutorial uses a simple insect with wings - both the binary and ascii versions:
• Models (.glb) (Bee.glb )
• Models (.gltf) (Bee.gltf)

Details about the model:
Number of Meshes: 1
Number of Nodes: 112
Number of Materials: 1
Number of Textures: 5
Number of Animations: 3
Number of Skins: 1

At this point, we need to load:
1. mesh (collection of vertex data - includes positions, joint indexes and weights)
2. joint data (hierarchy/skeleton/nodes)
3. animatoin data (control/move the joints)


The concept of skinning might seem like a lot of work - adding extra transform data to each vertex - when you've got a complex skinned model with hundreds of thousands of vertices - it might seem impossible? However, with tools like Blender or Maya; it's easy to setup the skinned mesh/file data - these specialist modeling packages help manage the complexity of designing and setting the weights so the final animated data is perfect.


Loading in the Mesh Data


The mesh for the Bee is a single set of vertex data (single mesh) - you can have multiple meshes/animations/skins inside a glTF file - but for this example, we have only a single mesh/skin.

Mesh 0 NameMesh_0
   Primitive 0
:
      
PrimitiveMode4   // 4 is the default for triangle list in glTF meshes
      
Number of Vertices29635
      Number of Indices
155193
      
...


If we look at a few of the vertices for the positions and indices, these are what we'd expect for any mesh:

Dump the first 12 vertex values
Vertex Position 01.475.965.64
Vertex Position 11.416.015.51
Vertex Position 21.306.085.64
Vertex Position 31.366.045.80
Vertex Position 41.236.085.47
Vertex Position 51.356.045.44
Vertex Position 61.355.344.70
Vertex Position 71.475.514.90
Vertex Position 81.495.364.80
Vertex Position 91.305.524.79
Vertex Position 101.635.455.01
Vertex Position 111.635.304.90
Vertex Position 121.755.195.00,
....


Now, let's dump the first 12 indices values (triangle index list):
Indices Triangle 0:  012
Indices Triangle 1:  230
Indices Triangle 2:  421
Indices Triangle 3:  415
Indices Triangle 4:  678
Indices Triangle 5:  769
Indices Triangle 6:  81011
Indices Triangle 7:  1087
Indices Triangle 8:  121113
Indices Triangle 9:  111013
Indices Triangle 10:  141516
Indices Triangle 11:  151417
Indices Triangle 12:  181920
...


These seems logical and correct (we're limiting the vertex data with floating point alues to 2 decimal places).

Now for the joint indices and weights - which are stored with the mesh data.

The joint indexes are stored as unsigned integers (not floating points) - and the index values should be within the joint range (i.e., 0 to number of joints - 1).

As there are thousands of vertices, let's dump the first 12, plus a few other ones later on - so you can see the values change.

Vertex Joint Index 02000,  
Vertex Joint Index 12000
Vertex Joint Index 22000
Vertex Joint Index 32000
Vertex Joint Index 42000
Vertex Joint Index 52000
Vertex Joint Index 62000
Vertex Joint Index 72000
Vertex Joint Index 82000
Vertex Joint Index 92000
Vertex Joint Index 102000
Vertex Joint Index 112000
Vertex Joint Index 122000,
...
Vertex Joint Index 2816888900
...
Vertex Joint Index 30729392940
...
Vertex Joint Index 3584909100
...
Vertex Joint Index 384093949592
....


We have the same number of weights as we do joints. A very important point to note - is the weights for each vertex should add up to 1.0.

Vertex Weights 01.000.000.000.00,  // Note the sum == 1.0!!!
Vertex Weights 11.000.000.000.00,  // == 1
Vertex Weights 21.000.000.000.00,  // == 1
Vertex Weights 31.000.000.000.00,  // == 1
Vertex Weights 41.000.000.000.00
Vertex Weights 51.000.000.000.00
Vertex Weights 61.000.000.000.00
Vertex Weights 71.000.000.000.00
Vertex Weights 81.000.000.000.00
Vertex Weights 91.000.000.000.00
Vertex Weights 101.000.000.000.00
Vertex Weights 111.000.000.000.00
Vertex Weights 121.000.000.000.00
...
Vertex Weights 33280.610.390.000.00// == 1
...
Vertex Weights 35840.900.100.000.00// == 1
...
Vertex Weights 38400.500.480.010.01
...


These values can be loaded into GPU buffers and passed to the vertex shader - for testing we can just render the mesh as we would any other mesh without the influence of the joints/weights. If the vertex data is correct the mesh should be shown on screen.

Vertex code snippet showing the vertex data being used without any skinning:

@vertex
fn vmain(inputVertexInput) -> VertexOutput {
    
let mvp transforms.projection transforms.view transforms.model;
    
output.position mvp vec4(input.position.xyz1.0);
...


With the skinning transforms, we modify this code to the following:

@vertex
fn vmain(inputVertexInput) -> VertexOutput {
    
let mvp transforms.projection transforms.view transforms.model;
    var 
skinnedMatrix mat4x4<f32> =
                    
input.weights.uniforms.bonesinput.joints.] +
                    
input.weights.uniforms.bonesinput.joints.] +
                    
input.weights.uniforms.bonesinput.joints.] +
                    
input.weights.uniforms.bonesinput.joints.];

    
// Transform the vertex position
    
let skinnedPosition skinnedMatrix vec4(input.position.xyz1.0);

    
output.position mvp skinnedPosition;


If the weight value is
0.0
that means the transforms has zero impact on the output and is nulled out.


Bone (or Joint) Data


Let's move onto the bones. We load the bone data in; and is nothing more than an array of transforms. These can be matrices or a mix - however, for animated bones local transforms as a position, scale and rotation - these are easier to interpolate between than matrices.

The glTF file format refers to the bones (or joints) as `nodes`.

For the Bee model, there are 112 nodes - that means their is 112 joints! (however, that does not mean all of them will be used for the skinned animation, but we need to load them all).

Loading and dumping the node data we would see this:

Node 0 Namebee.110
        nodeIndex
:0
        Translation
: Default (000)
        
Rotation: Default (0001)
        
Scale: Default (111)
        
MatrixNone
        Mesh Index
None
        Skin Index
None
        Child Nodes
None
Node 1 
Namegeo.109
        nodeIndex
:1
        Translation
: Default (000)
        
Rotation: Default (0001)
        
Scale: Default (111)
        
MatrixNone
        Mesh Index
None
        Skin Index
None
        Child Nodes
0
Node 2 
Namer_mandibre_jnt03.7
        nodeIndex
:2
        Translation
88.2,-0.0,-0.0,
        
Rotation0.0,-0.0,-0.0,1.0,
        
Scale1.000,1.000,1.000,
        
MatrixNone
        Mesh Index
None
        Skin Index
None
        Child Nodes
None
Node 3 
Namer_mandibre_jnt02.6
        nodeIndex
:3
        Translation
46.0,0.0,-0.0,
        
Rotation: -0.0,0.4,0.0,0.9,
        
Scale1.000,1.000,1.000,
        
MatrixNone
        Mesh Index
None
        Skin Index
None
        Child Nodes
2
Node 4 
Namer_mandibre_jnt01.5
        nodeIndex
:4
        Translation
340.5,-0.1,106.2,
        
Rotation: -0.0,0.1,-0.1,1.0,
        
Scale1.000,1.000,1.000,
        
MatrixNone
        Mesh Index
None
        Skin Index
None
        Child Nodes
3
....
Node 107 Namebody_jnt.3
        nodeIndex
:107
        Translation
: -0.0,471.1,40.6,
        
Rotation: -0.1,-0.7,0.1,0.7,
        
Scale: Default (111)
        
MatrixNone
        Mesh Index
None
        Skin Index
None
        Child Nodes
2833106
....


Dumped the first 5, plus node 107 - to show the variety of information. Important to note the nodes provide hierarchical information - which children are connected to each node. The transform for each node is a local transform only - so to get the world transform (which is what we need) we have to combined all the transforms from each child.

If we load all the nodes and connect them together in a hierarchy, we get something like this (use indentation to represent a 'child'):

RootNode.0 (Index111)
   
geo.109 (Index1)
      
bee.110 (Index0)
   
skeletal.1 (Index110)
      
1645859359680_0 (Index109)
      
root_jnt.2 (Index108)
         
body_jnt.3 (Index107)
            
head_Jnt.4 (Index28)
               
r_mandibre_jnt01.5 (Index4)
                  
r_mandibre_jnt02.6 (Index3)
                     
r_mandibre_jnt03.7 (Index2)
               
l_mandible_jnt01.8 (Index7)
                  
l_mandible_jnt02.9 (Index6)
                     
l_mandible_jnt03.10 (Index5)
               
l_antenna_jnt01.11 (Index12)
                  
l_antenna_jnt02.12 (Index11)
                     
l_antenna_jnt03.13 (Index10)
                        
l_antenna_jnt04.14 (Index9)
                           
l_antenna_jnt05.15 (Index8)
               
r_antenna_jnt01.16 (Index17)
                  
r_antenna_jnt02.17 (Index16)
                     
r_antenna_jnt03.18 (Index15)
                        
r_antenna_jnt04.19 (Index14)
                           
r_antenna_jnt05.20 (Index13)
               
l_big_labrum_jnt01.21 (Index20)
                  
l_big_labrum_jnt02.22 (Index19)
                     
l_big_labrum_endjnt.23 (Index18)
               
l_small_labrum_jnt01.24 (Index22)
                  
l_small_labrum_endjnt.25 (Index21)
               
r_big_labrum_jnt01.26 (Index25)
                  
r_big_labrum_jnt02.27 (Index24)
                     
r_big_labrum_endjnt.28 (Index23)
               
r_smalr_labrum_jnt01.29 (Index27)
                  
r_smalr_labrum_endjnt.30 (Index26)
            
abdomen_root_jnt.31 (Index33)
               
abdomen_jnt01.32 (Index32)
                  
abdomen_jnt02.33 (Index31)
                     
abdomen_jnt03.34 (Index30)
                        
abdomen_jnt04.35 (Index29)
            
thorax_jnt.36 (Index106)
               
r_wingroot_jnt.37 (Index39)
                  
r_wing_jnt01.38 (Index38)
                     
r_wing_jnt02.39 (Index37)
                        
r_wing_jnt03.40 (Index36)
                           
r_wing_jnt04.41 (Index35)
                              
r_wing_endjnt.42 (Index34)
               
l_wingroot_jnt.43 (Index45)
                  
l_wing_jnt01.44 (Index44)
                     
l_wing_jnt02.45 (Index43)
                        
l_wing_jnt03.46 (Index42)
                           
l_wing_jnt04.47 (Index41)
                              
l_wing_endjnt.48 (Index40)
               
l_foreleg_jnt01.49 (Index55)
                  
l_foreleg_jnt02.50 (Index54)
                     
l_foreleg_jnt03.51 (Index53)
                        
l_foreleg_jnt04.52 (Index52)
                           
l_foreleg_jnt05.53 (Index51)
                              
l_foreleg_jnt06.54 (Index50)
                                 
l_foreleg_jnt07.55 (Index49)
                                    
l_foreleg_jnt08.56 (Index48)
                                       
l_foreleg_jnt09.57 (Index47)
                                          
l_foreleg_jnt010.58 (Index46)
...


For the Bee model - everything is connected to the root node - however, this isn't a rule - you can have multiple root nodes - each with their own hierarchy! Just somethinig to be aware of if you try more complex animations/files/examples.

We construct a local matrix for each node (using the transform from each node). This is the `default` hierarchy transform for the idle test post (without any animation).

As the local transform is now a matrix for each node, we can recurse the hierarchy and build the world transform for each node. We start at the root and pass the transform down the hierarchy.

We do this in two parts:
1. Build the hierarchy
2. Update the hierarch with 'world' transforms

First, let's build the hierarchy from the array of nodes:

    // 'nodes' variable is an array of all the nodes loaded in earlier, build the hierarchy
    // use the node indexes
    
    
function buildHierarchy(nodeIndexnodes) {
        
// Get the current node
        
let node nodes[nodeIndex];

        if (!
node) {
            
console.error(`Node at index ${nodeIndex} not found!`);
            return 
null;
        }

        
// Recursively build children hierarchies
        
let children = (node.children || []).map(childIndex => buildHierarchy(childIndexnodes));

          
let localmat mat4.create();
        {
              if ( 
node.matrix )
            {
                
mat4.copy(localmatnode.matrix);
            }
              else
            {
                  
let scale       node.scale node.scale : [1,1,1];
                
let rotation    node.rotation node.rotation : [0,0,0,1];
                
let translation node.translation node.translation : [0,0,0];
              
                  
let rotMat   mat4.create();
                
let scaleMat mat4.create();
                
let transMat mat4.create();
                
rotMat   mat4.fromQuat(mat4.create(), rotation);
                
scaleMat mat4.fromScaling(mat4.create(), scale);
                
mat4.translate(transMatmat4.create(), translation);

                  
mat4.multiply(localmatlocalmattransMat);  // Finally translation  
                
mat4.multiply(localmatlocalmatrotMat);    // Then rotation
                
mat4.multiply(localmatlocalmatscaleMat);  // Scale first
                
                            
            
}
          
        }
      
        
// Return the node with its hierarchy
        
return {
            
indexnodeIndex,
            
namenode.name || `Unnamed Node ${nodeIndex}`,
            
localmatlocalmat,
            
worldmatmat4.create(),
            
// other information for each node (e.g., animation transforms)
            
childrenchildren,
        };
    }


Second, let's construct the world transform for each node using the parent-child relationship fo the hierarchy:

    function updateHierarchyMatrices(nodeparentMatrix) {

        
// Add in the animation transform data here later on

        // combine the local matrix with the parent
          
mat4.multiply(node.worldmatparentMatrixnode.localmat  );
        
      
        
// Recursively print children (pass this world matrix to the child)
        
(node.children || []).forEach(child => updateHierarchyMatrices(childnode.worldmat));
    } 


This gives us a hierarchy with transforms both in local and world space.

At this point, we've done a lot of work - but this is the step that hits most people hard - the inverse transforms! The transform for each node is in node space - and our vertex position is in mesh space.

We don't need to animate the inverse transforms to convert the vertex position to the joint space - so an array of inverse transforms is stored on the mesh.


Inverse Transforms


Each skin has an array of inverse transforms as an array of matrices (mat4x4) which we can load easily.

For example, loading the inverse matrices from the glTF file:

// Load inverse bind matrices
const inverseBindMatricesAccessor parsedData.accessors[skin.inverseBindMatrices];
const 
inverseBindMatricesBufferView parsedData.bufferViews[inverseBindMatricesAccessor.bufferView];
const 
inverseBindMatricesBuffer parsedData.buffers[inverseBindMatricesBufferView.buffer];
const 
inverseBindMatrices = new Float32Array(
  
inverseBindMatricesBuffer,
  
inverseBindMatricesBufferView.byteOffset + (inverseBindMatricesAccessor.byteOffset || 0),
  
inverseBindMatricesAccessor.count 16
);


Their is 107 inverse matrices. Remember, their is 112 nodes - not all nodes are used for skinning.

The skin has a list of indexes so we know which inverse matrix goes with which joint:

"skin.joints",[108,107,28,4,3,2,7,6,5,12,11,10,9,8,17,16,15,14,13,20,19,18,22,21,25,...]


We need to pass the array of matrices to the vertex shader which is used for the skinning (weights/joint indexes).

skin.joints.forEach((skinJointIndexindx) => {
       
// skin.joints.length == inverseBindMatricesArray.length
      
console.assertskin.joints.length == inverseBindMatricesArray.length );
      
console.assertinverseBindMatricesArray.length == jointMatrices.length );
       const 
inverseBindMatrix inverseBindMatricesArrayindx ];
 
      
// Find the node in our hierarchy using the joint index
      
let hnode findHierarchyNodehierarchyskinJointIndex );
      if ( !
hnode )
      {
            
console.warn('**unable to find animation node!!**'skinJointIndex);
            return;
      }

      
// Combine the inverse matrix with the world transform on the node
      
let jointmat mat4.create();
      
mat4.muljointmathnode.worldmatinverseBindMatrix );
      
jointMatricesindx ] = jointmat;
});


Now if we render the mesh using the node transforms we should get the pose view:

@vertex
fn vmain(inputVertexInput) -> VertexOutput {
    
let mvp transforms.projection transforms.view transforms.model;
    var 
skinnedMatrix mat4x4<f32> =
                    
input.weights.uniforms.bonesinput.joints.] +
                    
input.weights.uniforms.bonesinput.joints.] +
                    
input.weights.uniforms.bonesinput.joints.] +
                    
input.weights.uniforms.bonesinput.joints.];

    
// Transform the vertex position
    
let skinnedPosition skinnedMatrix vec4(input.position.xyz1.0);

    
output.position mvp skinnedPosition;



Default pose view using the node transforms (no animation data). Combines the inverse matrix with the node transform and is pas...
Default pose view using the node transforms (no animation data). Combines the inverse matrix with the node transform and is passed to the vertex shader which uses the weights/node indexes. View shows the solid and wireframe view - debug view so the depth buffer isn't enabled.



Animation Data


The animation data is the transform for each node that will replace the default node data. Important we don't combine it with the transform on the node, but replace it.

The animations are stored on their own in the glTF file - so we can loop over the animation data and get the timing information (e.g., maximum duration of the animation).

let maxDuration 0;

parsedData.animations.forEach(animation => {
  
animation.details.forEach(detail => {
    const 
sampler animation.samplers[detail.samplerIndex];

    
// Get the duration for this specific sampler
    
const samplerDuration sampler.inputData[sampler.inputData.length 1];

    
// Check if this duration is the maximum
    
if (samplerDuration maxDuration) {
      
maxDuration samplerDuration;
    }
  });
});

return 
maxDuration;


We can have any number of animations and within each animation is a set of
samples
. The sample is like a keyframe - so at that keyframe it can provide one or more position, rotation and scale transforms.

The samples are NOT equally spaced out - and use a time value - so we need to keep track of the prevous time and transform and interpolate between them.

The following provides an example of looping over the animations and calculating the transform for the node for a given time. If we have a currentTime of 2.0 - calculate the transform for that node.

We calculate the previous and current sample indexes and interpolate between them using the currentTime value.

We sample keyframe is linked to a node (using an index) - we store the animation transform on each node as
animTMat
,
animSMat
and
animRMat
for translation, scaling and rotation.

Remember, it might only have a translation transform or a rotation - it does not need to have all three;

parsedData.animations.forEach( (animationanimIndex) => {
    
animation.details.forEach(detail => {

          
let hnode findHierarchyNodehierarchydetail.index );
          if ( !
hnode )
        {
            
console.warn('**unable to find animation node!!**'detail);
            return;
        }

        const 
sampler animation.samplers[detail.samplerIndex];

        
// Get the duration for this specific sampler
        
const samplerDuration sampler.inputData[sampler.inputData.length 1];
        
let samplerTime currentTime;

        
// Each sampler can have a different end time
        
samplerTime clamp(samplerTime0.0samplerDuration 0.001);

        
// Find the current frame and next frame
        
let currentFrame 0;
        for (
let i 0sampler.inputData.length 1i++) {
            if (
samplerTime sampler.inputData[1]) {
                
currentFrame i;
                break;
            }
        }
        const 
nextFrame = (currentFrame 1) % sampler.inputData.length;

        
// Get time values for current and next frames
        
const currentTimeValue sampler.inputData[currentFrame];
        const 
nextTimeValue sampler.inputData[nextFrame];
        const 
deltaTime Math.max(nextTimeValue currentTimeValue1e-6);


        
let interpolationFactor = (samplerTime currentTimeValue) / deltaTime;
        
interpolationFactor clamp(interpolationFactor01);

        const 
outputLength detail.path === 'rotation' 3;
        const 
startOutput sampler.outputData.slice(currentFrame outputLength, (currentFrame 1) * outputLength);
        const 
endOutput sampler.outputData.slice(nextFrame outputLength, (nextFrame 1) * outputLength);

        const 
interpolatedOutput startOutput.map((starti) => {
            const 
end endOutput[i];
            if (
isNaN(end) || isNaN(start)) {
                
console.warn('Encountered NaN in animation output data.');
                return 
start// Fallback to start if NaN encountered
            
}
            return 
start + (end start) * interpolationFactor;
        });

        if (
detail.path === 'translation') {
            
let offset interpolatedOutput.slice(03);
            
let translationMatrix mat4.create();
            
mat4.translate(translationMatrixtranslationMatrixoffset);
              
hnode.animTMat translationMatrix;

        } else if (
detail.path === 'rotation') {
            
            
// Interpolate between the rotations and normalize (not SLERP)
            
let len 1.0 Math.sqrt(
                
interpolatedOutput[0] * interpolatedOutput[0] +
                
interpolatedOutput[1] * interpolatedOutput[1] +
                
interpolatedOutput[2] * interpolatedOutput[2] +
                
interpolatedOutput[3] * interpolatedOutput[3]
            );
              if (
isNaN(len) || len === 0) {
                
console.warn('Invalid quaternion length.');
                return;
            }
            
interpolatedOutput[0] *= len;
            
interpolatedOutput[1] *= len;
            
interpolatedOutput[2] *= len;
            
interpolatedOutput[3] *= len;

            
let rotationMatrix mat4.fromQuat(mat4.create(), interpolatedOutput);
              
hnode.animRMat rotationMatrix;
        }
        
// Handle scaling
        
else if (detail.path === 'scale') {
            
let scale interpolatedOutput.slice(03); // Expecting scale values for x, y, and z
            
let scaleMatrix mat4.create();
            
mat4.fromScaling(scaleMatrixscale);
              
hnode.animSMat scaleMatrix;
        }
        else {
            
console.log('error unknown animation:'detail.path );
        }              
    });
});


Now we have the animation transform on each node - we can modify the update hiearchy - instead of using the transform from each node - we use the transform from the animaton.


Draw multiple animation frames on top of one another showing the Bee moving around due to the animation. Flaps its wings and ta...
Draw multiple animation frames on top of one another showing the Bee moving around due to the animation. Flaps its wings and takes off - hovering and then coming down to land.


function updateHierarchyMatrices(nodeparentMatrix) {
      
let animmat mat4.create();
    
    
mat4.copyanimmatnode.localmat );
    
    
// If we don't have animation data for this node - use the default - we need
    // to check if we this! Not all nodes are animated
    
if ( node.animSMat  || node.animTMat || node.animRMat )
    {
       
let animSMat node.animSMat node.animSMat mat4.create();
       
let animTMat node.animTMat node.animTMat mat4.create();
       
let animRMat node.animRMat node.animRMat mat4.create();

       
       
animmat mat4.create();
       
mat4.multiply(animmatanimmatanimTMat);  // Finally translation
       
mat4.multiply(animmatanimmatanimRMat);  // Then rotation
       
mat4.multiply(animmatanimmatanimSMat);  // Scale first
    
}         
  
      
// combine both local node matrix and animation matrix
    // VERY IMPORTANT - The animation transform data **replaces** the
    // transform data on the nodes (i.e., local transforms)
    // new set of transforms
      
let tmp mat4.create();
      
    
mat4.copytmpanimmat );

    
// combine the new local matrix with the parent
      
mat4.multiply(node.worldmatparentMatrixtmp  );
    
    
// Recursively print children - pass the local world to the children of this node
    
(node.children || []).forEach(child => updateHierarchyMatrices(childnode.worldmat));




Use the currentTime of 5.0 - which moves the Bee position.
Use the currentTime of 5.0 - which moves the Bee position.



Things to Try


• The hierarchy animation has the Bee move around the screen - however, instead of having it fly around arbitarily - control it! Overide the base node position so you move the Bee around the screen programatically! Control it using the mouse cursor or cursor keys. Make it do loops or follow things around.

• Load in a 3d flower - and have the fly hover around the flower (override the position of the base node to control this)



Resources & Links


• View code/working demo (LINK)
• Tutorial (LINK)

























Ray-Tracing with WebGPU kenwright WebGPU Development Cookbook - coding recipes for all your webgpu needs! WebGPU by Example: Fractals, Image Effects, Ray-Tracing, Procedural Geometry, 2D/3D, Particles, Simulations WebGPU Games WGSL 2d 3d interactive web-based fun learning WebGPU Compute WebGPU API - Owners WebGPU & WGSL Essentials: A Hands-On Approach to Interactive Graphics, Games, 2D Interfaces, 3D Meshes, Animation, Security and Production Kenwright graphics and animations using the webgpu api 12 week course kenwright learn webgpu api kenwright programming compute and graphics applications with html5 and webgpu api kenwright real-time 3d graphics with webgpu kenwright webgpu for dummies kenwright webgpu api develompent a quick start guide kenwright webgpu by example 2022 kenwright webgpu gems kenwright webgpu interactive compute and graphics visualization cookbook kenwright wgsl webgpu shading language cookbook kenwright WebGPU Shader Language Development: Vertex, Fragment, Compute Shaders for Programmers Kenwright wgsl webgpugems shading language cookbook kenwright WGSL Fundamentals book kenwright WebGPU Data Visualization Cookbook kenwright Special Effects Programming with WebGPU kenwright WebGPU Programming Guide: Interactive Graphics and Compute Programming with WebGPU & WGSL kenwright



 
Advert (Support Website)

 
 Visitor:
Copyright (c) 2002-2025 xbdev.net - All rights reserved.
Designated articles, tutorials and software are the property of their respective owners.