/* A small WebGL renderer for the X2's URDF model. Deliberately hand-rolled rather than pulled from a 3D library. What this needs is narrow - one shader, flat shading, a rigid-body tree of 41 links - and the alternative is several hundred kilobytes fetched over the robot's own Wi-Fi, from a CDN the robot cannot reach. Geometry arrives as uint16 positions quantised inside each mesh's bounding box (see build_model.py); the shader expands them back to metres, so the 4x saving over float32 costs two extra instructions per vertex. Normals are computed per-face in the fragment shader from screen-space derivatives. That means the vertex buffer carries positions and nothing else - no normal buffer, no smoothing groups, no index-order assumptions - and flat shading is the honest look for a machined-metal robot anyway. */ const VERTEX_SHADER = ` attribute vec3 a_quantised; uniform mat4 u_viewProjection; uniform mat4 u_model; uniform vec3 u_lo; uniform vec3 u_span; varying vec3 v_world; void main() { vec3 local = u_lo + (a_quantised / 65535.0) * u_span; vec4 world = u_model * vec4(local, 1.0); v_world = world.xyz; gl_Position = u_viewProjection * world; }`; const FRAGMENT_SHADER = ` precision mediump float; varying vec3 v_world; uniform vec3 u_colour; uniform float u_alpha; void main() { // Face normal straight from the derivative of world position across the // triangle. No normal attribute needed, and it cannot disagree with the // geometry the way a stale baked normal can. vec3 normal = normalize(cross(dFdx(v_world), dFdy(v_world))); vec3 keyDir = normalize(vec3(0.45, 0.7, 0.85)); vec3 fillDir = normalize(vec3(-0.6, -0.3, 0.4)); float key = max(dot(normal, keyDir), 0.0); float fill = max(dot(normal, fillDir), 0.0) * 0.35; // A little rim light so the silhouette stays readable against a dark panel. float rim = pow(1.0 - abs(normal.z), 2.0) * 0.18; vec3 shaded = u_colour * (0.30 + 0.72 * key + fill) + rim; gl_FragColor = vec4(shaded, u_alpha); }`; /* -- Small matrix helpers (column-major, as WebGL expects) ----------------- */ export function identity() { return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); } export function multiply(a, b) { const out = new Float32Array(16); for (let col = 0; col < 4; col += 1) { for (let row = 0; row < 4; row += 1) { out[col * 4 + row] = a[row] * b[col * 4] + a[4 + row] * b[col * 4 + 1] + a[8 + row] * b[col * 4 + 2] + a[12 + row] * b[col * 4 + 3]; } } return out; } /** URDF fixed-axis roll-pitch-yaw: R = Rz(yaw) * Ry(pitch) * Rx(roll). */ export function fromOrigin(xyz, rpy) { const [x, y, z] = xyz; const [roll, pitch, yaw] = rpy; const cr = Math.cos(roll), sr = Math.sin(roll); const cp = Math.cos(pitch), sp = Math.sin(pitch); const cy = Math.cos(yaw), sy = Math.sin(yaw); return new Float32Array([ cy * cp, sy * cp, -sp, 0, cy * sp * sr - sy * cr, sy * sp * sr + cy * cr, cp * sr, 0, cy * sp * cr + sy * sr, sy * sp * cr - cy * sr, cp * cr, 0, x, y, z, 1, ]); } /** Rotation of `angle` about an arbitrary unit axis (Rodrigues). */ export function fromAxisAngle(axis, angle) { let [x, y, z] = axis; const length = Math.hypot(x, y, z) || 1; x /= length; y /= length; z /= length; const c = Math.cos(angle), s = Math.sin(angle), t = 1 - c; return new Float32Array([ t * x * x + c, t * x * y + s * z, t * x * z - s * y, 0, t * x * y - s * z, t * y * y + c, t * y * z + s * x, 0, t * x * z + s * y, t * y * z - s * x, t * z * z + c, 0, 0, 0, 0, 1, ]); } export function perspective(fovY, aspect, near, far) { const f = 1 / Math.tan(fovY / 2); return new Float32Array([ f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / (near - far), -1, 0, 0, (2 * far * near) / (near - far), 0, ]); } export function lookAt(eye, target, up) { const zx = eye[0] - target[0], zy = eye[1] - target[1], zz = eye[2] - target[2]; const zl = Math.hypot(zx, zy, zz) || 1; const z = [zx / zl, zy / zl, zz / zl]; const xx = up[1] * z[2] - up[2] * z[1]; const xy = up[2] * z[0] - up[0] * z[2]; const xz = up[0] * z[1] - up[1] * z[0]; const xl = Math.hypot(xx, xy, xz) || 1; const x = [xx / xl, xy / xl, xz / xl]; const y = [ z[1] * x[2] - z[2] * x[1], z[2] * x[0] - z[0] * x[2], z[0] * x[1] - z[1] * x[0], ]; return new Float32Array([ x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -(x[0] * eye[0] + x[1] * eye[1] + x[2] * eye[2]), -(y[0] * eye[0] + y[1] * eye[1] + y[2] * eye[2]), -(z[0] * eye[0] + z[1] * eye[1] + z[2] * eye[2]), 1, ]); } /* -- Renderer -------------------------------------------------------------- */ export class RobotModel { constructor(canvas) { this.canvas = canvas; this.gl = canvas.getContext('webgl', { antialias: true, alpha: false, depth: true, preserveDrawingBuffer: false, }); if (!this.gl) throw new Error('This browser has no WebGL support.'); // Flat shading needs dFdx/dFdy, which is an extension in WebGL 1. Without // it the shader still links on most drivers but every face comes out // uniformly lit, so say so rather than render something misleading. this.derivatives = this.gl.getExtension('OES_standard_derivatives'); this.model = null; this.parts = []; this.jointAngles = new Map(); this.jointsByChild = new Map(); this.linkParent = new Map(); this.colour = [0.62, 0.66, 0.72]; this.accent = [0.22, 0.53, 0.90]; this.highlight = new Set(); // link name -> [r, g, b]. Anything not listed falls back to this.colour, so // a link whose joint reports nothing stays neutral grey rather than // rendering as "zero load", which would read as a measurement. this.linkColours = new Map(); this._buildProgram(); } _buildProgram() { const gl = this.gl; const prefix = this.derivatives ? '#extension GL_OES_standard_derivatives : enable\n' : ''; const compile = (type, source) => { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { throw new Error(`Shader failed to compile: ${gl.getShaderInfoLog(shader)}`); } return shader; }; const program = gl.createProgram(); gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX_SHADER)); gl.attachShader(program, compile(gl.FRAGMENT_SHADER, prefix + FRAGMENT_SHADER)); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { throw new Error(`Shader failed to link: ${gl.getProgramInfoLog(program)}`); } this.program = program; this.attribs = { quantised: gl.getAttribLocation(program, 'a_quantised') }; this.uniforms = { viewProjection: gl.getUniformLocation(program, 'u_viewProjection'), model: gl.getUniformLocation(program, 'u_model'), lo: gl.getUniformLocation(program, 'u_lo'), span: gl.getUniformLocation(program, 'u_span'), colour: gl.getUniformLocation(program, 'u_colour'), alpha: gl.getUniformLocation(program, 'u_alpha'), }; } /** Upload the baked model. `geometry` is the raw model.bin ArrayBuffer. */ load(model, geometry) { const gl = this.gl; this.model = model; this.parts = []; for (const joint of model.joints) { this.jointsByChild.set(joint.child, joint); this.linkParent.set(joint.child, joint.parent); if (joint.type === 'revolute') this.jointAngles.set(joint.name, 0); } for (const [linkName, link] of Object.entries(model.links)) { for (const mesh of link.meshes) { const vertexBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Uint16Array(geometry, mesh.vertex_offset, mesh.vertex_count * 3), gl.STATIC_DRAW); const indexBuffer = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer); const indices = mesh.index_bits === 16 ? new Uint16Array(geometry, mesh.index_offset, mesh.index_count) : new Uint32Array(geometry, mesh.index_offset, mesh.index_count); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW); this.parts.push({ link: linkName, vertexBuffer, indexBuffer, count: mesh.index_count, type: mesh.index_bits === 16 ? gl.UNSIGNED_SHORT : gl.UNSIGNED_INT, lo: mesh.lo, span: mesh.span, local: fromOrigin(mesh.origin.xyz, mesh.origin.rpy), }); } } // 32-bit indices are an extension in WebGL 1. Every mesh here is under // 65535 vertices so it should never come up, but a silent wrong-geometry // render is worse than a clear failure. if (this.parts.some((part) => part.type === gl.UNSIGNED_INT) && !gl.getExtension('OES_element_index_uint')) { throw new Error('This browser cannot draw 32-bit indices (OES_element_index_uint).'); } } setJoint(name, radians) { if (this.jointAngles.has(name)) this.jointAngles.set(name, radians); } setJoints(values) { for (const [name, radians] of Object.entries(values)) this.setJoint(name, radians); } /** World transform for a link, walking up to the root through its joints. */ worldTransform(linkName) { const chain = []; let current = linkName; // Guarded against a malformed tree: a cycle would otherwise hang the frame. for (let depth = 0; current && depth < 64; depth += 1) { const joint = this.jointsByChild.get(current); if (!joint) break; chain.push(joint); current = joint.parent; } let matrix = identity(); for (const joint of chain) { let local = fromOrigin(joint.origin.xyz, joint.origin.rpy); if (joint.type === 'revolute') { const angle = this.jointAngles.get(joint.name) || 0; local = multiply(local, fromAxisAngle(joint.axis, angle)); } matrix = multiply(local, matrix); } return matrix; } resize() { const gl = this.gl; // Cap the backing store at 2x CSS pixels: past that the extra fragments // cost real milliseconds on a phone and buy nothing visible. const ratio = Math.min(window.devicePixelRatio || 1, 2); const width = Math.round(this.canvas.clientWidth * ratio); const height = Math.round(this.canvas.clientHeight * ratio); if (width && height && (this.canvas.width !== width || this.canvas.height !== height)) { this.canvas.width = width; this.canvas.height = height; } gl.viewport(0, 0, this.canvas.width, this.canvas.height); } render(camera, background = [0.09, 0.10, 0.12]) { const gl = this.gl; this.resize(); gl.clearColor(background[0], background[1], background[2], 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); if (!this.model) return; gl.enable(gl.DEPTH_TEST); // Backface culling stays off: vertex clustering does not preserve winding // reliably, so culling by winding punches holes in the model. Depth testing // alone is correct here; the cost is shading some faces we then overwrite. gl.disable(gl.CULL_FACE); const aspect = this.canvas.width / Math.max(1, this.canvas.height); const eye = [ camera.target[0] + camera.distance * Math.cos(camera.pitch) * Math.cos(camera.yaw), camera.target[1] + camera.distance * Math.cos(camera.pitch) * Math.sin(camera.yaw), camera.target[2] + camera.distance * Math.sin(camera.pitch), ]; const viewProjection = multiply( perspective(camera.fov ?? 0.8, aspect, 0.05, 60), // Z is up in URDF, so the camera's up vector is Z, not Y. lookAt(eye, camera.target, [0, 0, 1]), ); gl.useProgram(this.program); gl.uniformMatrix4fv(this.uniforms.viewProjection, false, viewProjection); gl.enableVertexAttribArray(this.attribs.quantised); const worldCache = new Map(); for (const part of this.parts) { let world = worldCache.get(part.link); if (!world) { world = this.worldTransform(part.link); worldCache.set(part.link, world); } gl.uniformMatrix4fv(this.uniforms.model, false, multiply(world, part.local)); gl.uniform3fv(this.uniforms.lo, part.lo); gl.uniform3fv(this.uniforms.span, part.span); const tinted = this.linkColours.get(part.link); const colour = tinted || (this.highlight.has(part.link) ? this.accent : this.colour); gl.uniform3fv(this.uniforms.colour, colour); gl.uniform1f(this.uniforms.alpha, 1.0); gl.bindBuffer(gl.ARRAY_BUFFER, part.vertexBuffer); // normalized=false: the shader divides by 65535 itself, because // normalising here would also apply to the index-style values and makes // the dequantisation harder to follow. gl.vertexAttribPointer(this.attribs.quantised, 3, gl.UNSIGNED_SHORT, false, 0, 0); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, part.indexBuffer); gl.drawElements(gl.TRIANGLES, part.count, part.type, 0); } } dispose() { const gl = this.gl; for (const part of this.parts) { gl.deleteBuffer(part.vertexBuffer); gl.deleteBuffer(part.indexBuffer); } this.parts = []; gl.deleteProgram(this.program); // Free the drawing buffer immediately rather than waiting for GC. Browsers // cap the number of live WebGL contexts (often 16), and navigating between // tabs repeatedly would otherwise start losing them. gl.getExtension('WEBGL_lose_context')?.loseContext(); } }