 | Animated Character Instancing (MD2) |  |
50 thousand animated characters all jumping and moving in real-time on the screen in a web-browser using instancing with the WebGPU API.
The .md2 format is a bit old now, but it does the job, it stores the vertices for each frame - and is interpolated (morphed) to create the animation effect.
The full md2 mesh loading code is included below - simple and sweet! Essentially, it's just a header section whilc include offsets to the mesh data (e.g., vertices, faces, texture coordinates etc).
Once the data is read in and the vertex/index buffers are setup for the WebGPU API! You're ready to go! Render a single instance! If you can render 1, you can render as many as you want.
To manage the instances - so they aren't all drawn on top of one another - you can use the @builtin(instance_index) variable in the WGSL shader - so that each instance is drawn at a differnet location and the frame number for the animation is offset (so they each animate/move differently).
Model Loader/Parser
The following code loads the 3d model data from the md2 file - while the md2 file is a bit old - it's also very compact and easy to implement. Essentially each animation frame stores all the vertices.
Then for each frame the previous and next vertex values (for the position) are interpolated. Typically, the md2 file contains 192ish animation frame - which form idling, walking, jumping and so on.
<script type='module'> // Function to load a binary file function loadBinaryFile(url) { return fetch(url) .then(response => response.arrayBuffer()); }
// Function to parse the MD2 file function parseMD2(buffer) { const dataView = new DataView(buffer);
const header = { ident: dataView.getUint32(0, true), version: dataView.getUint32(4, true), skinWidth: dataView.getUint32(8, true), skinHeight: dataView.getUint32(12, true), frameSize: dataView.getUint32(16, true), numSkins: dataView.getUint32(20, true), numVertices: dataView.getUint32(24, true), numTexCoords: dataView.getUint32(28, true), numTriangles: dataView.getUint32(32, true), numGlCommands: dataView.getUint32(36, true), numFrames: dataView.getUint32(40, true), offsetSkins: dataView.getUint32(44, true), offsetTexCoords: dataView.getUint32(48, true), offsetTriangles: dataView.getUint32(52, true), offsetFrames: dataView.getUint32(56, true), offsetGlCommands: dataView.getUint32(60, true), offsetEnd: dataView.getUint32(64, true) };
// Ensure the file is a valid MD2 file if (header.ident !== 844121161 || header.version !== 8) { throw new Error("Not a valid MD2 file"); }
console.log('header.offsetTriangles:', header.offsetTriangles ); let faces = []; let offset = header.offsetTriangles; for (let i = 0; i < header.numTriangles; i++) { let i0 = dataView.getUint16(offset, true); let i1 = dataView.getUint16(offset+2, true); let i2 = dataView.getUint16(offset+4, true); faces.push( i0, i1, i2 ); offset += 12; } faces = faces.flat(3); // Extract frame data const frames = []; offset = header.offsetFrames;
for (let i = 0; i < header.numFrames; i++) { const frame = { scale: [ dataView.getFloat32(offset, true), dataView.getFloat32(offset + 4, true), dataView.getFloat32(offset + 8, true) ], translate: [ dataView.getFloat32(offset + 12, true), dataView.getFloat32(offset + 16, true), dataView.getFloat32(offset + 20, true) ], name: '', vertices: [] };
offset += 24; const nameBytes = new Uint8Array(buffer, offset, 16); frame.name = String.fromCharCode(...nameBytes).replace(/\0/g, ''); offset += 16;
for (let j = 0; j < header.numVertices; j++) { let x = dataView.getUint8(offset) * frame.scale[0] + frame.translate[0]; let y = dataView.getUint8(offset + 1) * frame.scale[1] + frame.translate[1]; let z = dataView.getUint8(offset + 2) * frame.scale[2] + frame.translate[2]; const vertex = { pos: [ x, z, -y // swizzle xyz => xz(-y) ], normalIndex: dataView.getUint8(offset + 3) }; frame.vertices.push(vertex); offset += 4; }
frames.push(frame); } return { header, frames, faces }; }
// Example usage // xbdev.net let buffer2 = await loadBinaryFile('https://webgpulab.xbdev.net/var/resources/pac3D.md2'); const model = parseMD2(buffer2);
console.log('header:', model.header ); console.log('number frames:', model.frames.length ); console.log('number frames:', Object.keys(model.frames[0]) ); console.log('number frames[0] name:', model.frames[0].name ); console.log('number frames[0] pos:', model.frames[0].vertices[0].pos ); console.log('number frames[0] normalIndex:', model.frames[0].vertices[0].normalIndex );
Example output for the console log:
|