Download client-apps/web/src/lib/splat/SplatRenderer.ts from AIBRUH/miranda-engine: direct link, hf CLI and curl.
- Browser
- Download file 23 kB
-
https://huggingface.co/AIBRUH/miranda-engine/resolve/main/client-apps/web/src/lib/splat/SplatRenderer.ts
- Command line
-
hf download hf://AIBRUH/miranda-engine/client-apps/web/src/lib/splat/SplatRenderer.ts
-
curl -L -o SplatRenderer.ts https://huggingface.co/AIBRUH/miranda-engine/resolve/main/client-apps/web/src/lib/splat/SplatRenderer.ts
23 kB
| /** | |
| * WO-5 T4 — the WebGPU Gaussian-splat renderer. | |
| * | |
| * # The decoupled render loop (WO-5 architectural guardrail) | |
| * | |
| * `requestAnimationFrame` in `startRenderLoop()` is completely independent | |
| * of the DataChannel/WebSocket message handler. The network side | |
| * (`mirandaTransport.ts`) only ever writes "the latest known frame" into a | |
| * small piece of shared state (via `getLastFrame()`); the render loop reads | |
| * whatever is currently there every single frame, regardless of whether a | |
| * new packet has arrived since the last one. If packets stop arriving — | |
| * dropped connection, a slow network, a paused Rust dispatcher — the render | |
| * loop keeps calling `requestAnimationFrame` and keeps rendering, holding | |
| * the last known pose (extrapolation is intentionally NOT implemented as | |
| * velocity-based dead reckoning here: with only position+rotation and no | |
| * velocity channel over the wire, extrapolating would require inventing | |
| * motion that was never actually commanded, which is worse than holding | |
| * still — WO-3's compositor already guarantees the SOURCE never sends a | |
| * static frame, so a held frame here is stale, not artificial). | |
| * | |
| * This is the actual, verifiable mechanism behind "the avatar must never | |
| * freeze because of a network stall": the render loop's control flow does | |
| * not go through the network callback at all. | |
| * | |
| * # Two-pass GPU pipeline | |
| * | |
| * See `shaders.wgsl.ts`'s module docs for the deform-compute + splat-render | |
| * pass structure. This class owns the buffers, uniform updates, and the | |
| * two `GPUCommandEncoder` passes per frame. | |
| */ | |
| import { buildPlaceholderSplat, type SplatData } from "./placeholderSplat"; | |
| import { DEFORM_SHADER, SPLAT_FRAGMENT_SHADER, SPLAT_VERTEX_SHADER } from "./shaders.wgsl"; | |
| import { ARKIT_CANONICAL_ORDER, BLENDSHAPE_COUNT, type DecodedFrame } from "@/lib/ace/mirandaTransport"; | |
| /** Number of vec4<f32> slots the packed FaceUniforms.weights array uses. | |
| * 52 channels / 4 per vec4, rounded up = 13. Matches shaders.wgsl.ts. */ | |
| const FACE_WEIGHTS_VEC4_COUNT = Math.ceil(BLENDSHAPE_COUNT / 4); // 13 | |
| /** Total FaceUniforms buffer size in bytes: 13 vec4s (208 bytes) + one | |
| * padding vec4 (16 bytes) = 224 bytes. WGSL uniform buffer struct members | |
| * must be 16-byte aligned; array<vec4<f32>, 13> already satisfies that | |
| * per-element, and the whole struct's size must be a multiple of its | |
| * largest member alignment (16), which 224 already is — the explicit | |
| * `_pad` field in the WGSL struct exists so this stays true if a field is | |
| * ever appended without recomputing this constant by hand. */ | |
| const FACE_UNIFORMS_BYTES = (FACE_WEIGHTS_VEC4_COUNT + 1) * 16; // 224 | |
| /** CameraUniforms buffer size: mat4x4<f32> (64 bytes) + 2 vec4 (32 bytes) = 96. */ | |
| const CAMERA_UNIFORMS_BYTES = 64 + 16 + 16; | |
| export interface SplatRendererStats { | |
| framesRendered: number; | |
| lastFrameTimeMs: number; | |
| /** How many render frames had no new network data available at all | |
| * (never received a first frame) — distinct from "held a stale frame", | |
| * which is normal steady-state operation, not a fault. */ | |
| framesWithNoDataYet: number; | |
| /** Milliseconds since the currently-held frame was received. Large | |
| * values indicate a stalled or slow network, surfaced for telemetry — | |
| * not used to change render behavior. */ | |
| frameAgeMs: number; | |
| } | |
| /** | |
| * Owns the WebGPU device, pipelines, and buffers for one splat viewport. | |
| */ | |
| export class SplatRenderer { | |
| private device: GPUDevice; | |
| private context: GPUCanvasContext; | |
| private canvas: HTMLCanvasElement; | |
| private format: GPUTextureFormat; | |
| private splatData: SplatData; | |
| // Deform pass resources | |
| private deformPipeline: GPUComputePipeline; | |
| private deformBindGroup: GPUBindGroup; | |
| private faceUniformBuffer: GPUBuffer; | |
| private deformedPositionsBuffer: GPUBuffer; | |
| /** | |
| * Deformed rotation output from the compute pass. Not consumed by the | |
| * render pass: the billboard technique in SPLAT_VERTEX_SHADER doesn't | |
| * need per-splat rotation (see that shader's module note on why | |
| * binding it there would be a layout error, not just dead code). Kept | |
| * as a real GPU resource — exposed via `getDeformedRotationsBuffer()` | |
| * — because the natural next step, full covariance-to-screen-space | |
| * ellipse projection instead of a symmetric billboard, DOES need it. | |
| */ | |
| private deformedRotationsBuffer: GPUBuffer; | |
| // Render pass resources. | |
| // Note: the scales/colors storage buffers are intentionally NOT held as | |
| // fields. They are referenced by `renderBindGroup`, which keeps them | |
| // alive for as long as the bind group exists — holding a second | |
| // reference here would imply this class mutates them per frame, which | |
| // it does not (they are rest-pose constants uploaded once). | |
| private renderPipeline: GPURenderPipeline; | |
| private renderBindGroup: GPUBindGroup; | |
| private cameraUniformBuffer: GPUBuffer; | |
| private stats: SplatRendererStats = { | |
| framesRendered: 0, | |
| lastFrameTimeMs: 0, | |
| framesWithNoDataYet: 0, | |
| frameAgeMs: 0, | |
| }; | |
| private rafHandle: number | null = null; | |
| private stopped = false; | |
| /** Latest weights to apply, written by whoever is feeding this renderer | |
| * (the network client, or a test harness). Read every render frame | |
| * regardless of when it was last written — see the class docs on the | |
| * decoupled render loop. */ | |
| private currentWeights: Float32Array = new Float32Array(BLENDSHAPE_COUNT); | |
| private hasReceivedAnyFrame = false; | |
| private lastFrameReceivedAtMs = 0; | |
| private constructor( | |
| device: GPUDevice, | |
| context: GPUCanvasContext, | |
| canvas: HTMLCanvasElement, | |
| format: GPUTextureFormat, | |
| splatData: SplatData, | |
| ) { | |
| this.device = device; | |
| this.context = context; | |
| this.canvas = canvas; | |
| this.format = format; | |
| this.splatData = splatData; | |
| const deform = this.buildDeformPass(); | |
| this.deformPipeline = deform.pipeline; | |
| this.deformBindGroup = deform.bindGroup; | |
| this.faceUniformBuffer = deform.faceUniformBuffer; | |
| this.deformedPositionsBuffer = deform.deformedPositionsBuffer; | |
| this.deformedRotationsBuffer = deform.deformedRotationsBuffer; | |
| const render = this.buildRenderPass(); | |
| this.renderPipeline = render.pipeline; | |
| this.renderBindGroup = render.bindGroup; | |
| this.cameraUniformBuffer = render.cameraUniformBuffer; | |
| } | |
| /** | |
| * Requests a GPU adapter/device and sets up the canvas context. | |
| * | |
| * Throws if WebGPU is unavailable — callers are expected to catch this | |
| * and fall back to the existing L0 CSS-compositor presence layer, which | |
| * is exactly why the renderer node is marked `requiredFrom: "L2"` and | |
| * `warmNodes` at every earlier stage in `aceTopology.ts`: L0/L1 must | |
| * work with zero WebGPU support. | |
| */ | |
| static async create( | |
| canvas: HTMLCanvasElement, | |
| splatData: SplatData = buildPlaceholderSplat(), | |
| ): Promise<SplatRenderer> { | |
| if (!("gpu" in navigator)) { | |
| throw new Error("WebGPU is not available in this browser"); | |
| } | |
| const adapter = await navigator.gpu.requestAdapter(); | |
| if (!adapter) { | |
| throw new Error("No WebGPU adapter available"); | |
| } | |
| const device = await adapter.requestDevice(); | |
| const context = canvas.getContext("webgpu"); | |
| if (!context) { | |
| throw new Error("Failed to acquire a webgpu canvas context"); | |
| } | |
| const format = navigator.gpu.getPreferredCanvasFormat(); | |
| context.configure({ device, format, alphaMode: "premultiplied" }); | |
| // A <canvas> element's backing store defaults to 300x150 regardless | |
| // of its CSS-driven display size, which would render at that fixed | |
| // resolution stretched to fill the layout (blurry, wrong aspect). | |
| // Size the backing store from the actual displayed size, in device | |
| // pixels, before the first frame. | |
| resizeCanvasToDisplaySize(canvas); | |
| return new SplatRenderer(device, context, canvas, format, splatData); | |
| } | |
| private buildDeformPass() { | |
| const device = this.device; | |
| const count = this.splatData.count; | |
| const restPositions = toVec4Buffer(this.splatData.positions, count, 1.0); | |
| const restRotations = this.splatData.rotations; // already vec4-shaped | |
| const restPositionsBuffer = createStorageBuffer(device, restPositions, "restPositions"); | |
| const restRotationsBuffer = createStorageBuffer(device, restRotations, "restRotations"); | |
| const jawWeightsBuffer = createStorageBuffer(device, this.splatData.jawWeights, "jawWeights"); | |
| const eyelidWeightsBuffer = createStorageBuffer(device, this.splatData.eyelidWeights, "eyelidWeights"); | |
| const deformedPositionsBuffer = device.createBuffer({ | |
| label: "deformedPositions", | |
| size: count * 16, | |
| usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, | |
| }); | |
| const deformedRotationsBuffer = device.createBuffer({ | |
| label: "deformedRotations", | |
| size: count * 16, | |
| usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, | |
| }); | |
| const faceUniformBuffer = device.createBuffer({ | |
| label: "faceUniforms", | |
| size: FACE_UNIFORMS_BYTES, | |
| usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, | |
| }); | |
| const module = device.createShaderModule({ label: "deform", code: DEFORM_SHADER }); | |
| const pipeline = device.createComputePipeline({ | |
| label: "deformPipeline", | |
| layout: "auto", | |
| compute: { module, entryPoint: "deform_main" }, | |
| }); | |
| const bindGroup = device.createBindGroup({ | |
| label: "deformBindGroup", | |
| layout: pipeline.getBindGroupLayout(0), | |
| entries: [ | |
| { binding: 0, resource: { buffer: faceUniformBuffer } }, | |
| { binding: 1, resource: { buffer: restPositionsBuffer } }, | |
| { binding: 2, resource: { buffer: restRotationsBuffer } }, | |
| { binding: 3, resource: { buffer: jawWeightsBuffer } }, | |
| { binding: 4, resource: { buffer: eyelidWeightsBuffer } }, | |
| { binding: 5, resource: { buffer: deformedPositionsBuffer } }, | |
| { binding: 6, resource: { buffer: deformedRotationsBuffer } }, | |
| ], | |
| }); | |
| return { pipeline, bindGroup, faceUniformBuffer, deformedPositionsBuffer, deformedRotationsBuffer }; | |
| } | |
| private buildRenderPass() { | |
| const device = this.device; | |
| const count = this.splatData.count; | |
| const scales = toVec4Buffer(this.splatData.scales, count, 0.0); | |
| const colors = toVec4ColorBuffer(this.splatData.colors, this.splatData.opacities, count); | |
| const scalesBuffer = createStorageBuffer(device, scales, "scales"); | |
| const colorsBuffer = createStorageBuffer(device, colors, "colors"); | |
| const cameraUniformBuffer = device.createBuffer({ | |
| label: "cameraUniforms", | |
| size: CAMERA_UNIFORMS_BYTES, | |
| usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, | |
| }); | |
| const vertexModule = device.createShaderModule({ label: "splatVertex", code: SPLAT_VERTEX_SHADER }); | |
| const fragmentModule = device.createShaderModule({ label: "splatFragment", code: SPLAT_FRAGMENT_SHADER }); | |
| const pipeline = device.createRenderPipeline({ | |
| label: "splatRenderPipeline", | |
| layout: "auto", | |
| vertex: { module: vertexModule, entryPoint: "vertex_main" }, | |
| fragment: { | |
| module: fragmentModule, | |
| entryPoint: "fragment_main", | |
| targets: [ | |
| { | |
| format: this.format, | |
| blend: { | |
| color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" }, | |
| alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" }, | |
| }, | |
| }, | |
| ], | |
| }, | |
| primitive: { topology: "triangle-strip" }, | |
| }); | |
| const bindGroup = device.createBindGroup({ | |
| label: "splatRenderBindGroup", | |
| layout: pipeline.getBindGroupLayout(0), | |
| entries: [ | |
| { binding: 0, resource: { buffer: cameraUniformBuffer } }, | |
| { binding: 1, resource: { buffer: this.deformedPositionsBuffer } }, | |
| { binding: 2, resource: { buffer: scalesBuffer } }, | |
| { binding: 3, resource: { buffer: colorsBuffer } }, | |
| ], | |
| }); | |
| return { pipeline, bindGroup, cameraUniformBuffer }; | |
| } | |
| /** | |
| * Updates the target ARKit weights. | |
| * | |
| * Deliberately NOT wired to React state anywhere in this codebase. | |
| * `SplatViewport` calls this from its OWN `requestAnimationFrame` loop, | |
| * reading directly from `mirandaTransport.getLastFrame()` — never via a | |
| * `useState` setter. A `setState` call per network frame would force a | |
| * React re-render at up to 60 Hz, exactly what `EvePresenceViewport`'s | |
| * module docs identify as the thing to avoid on this project's | |
| * dual-core target. This method itself does not trigger a render | |
| * either; `renderFrame`/`startRenderLoop` read `currentWeights` on | |
| * their own schedule, which is the actual decoupling this class exists | |
| * to provide. | |
| */ | |
| setWeights(frame: DecodedFrame, receivedAtMs: number) { | |
| // Skip the copy entirely if this is the same frame already applied — | |
| // a frame source polled every rAF tick will report the same | |
| // receivedAtMs many times between real network arrivals (60Hz | |
| // render loop vs. whatever rate frames actually arrive at). | |
| if (receivedAtMs === this.lastFrameReceivedAtMs) return; | |
| for (let i = 0; i < BLENDSHAPE_COUNT; i += 1) { | |
| this.currentWeights[i] = frame.weights[ARKIT_CANONICAL_ORDER[i]] ?? 0; | |
| } | |
| this.hasReceivedAnyFrame = true; | |
| this.lastFrameReceivedAtMs = receivedAtMs; | |
| } | |
| getStats(): SplatRendererStats { | |
| return { ...this.stats }; | |
| } | |
| /** Exposes the deform pass's rotation output buffer, for a future | |
| * covariance-projection render pass or for debugging/verification | |
| * tooling. Not used by the current billboard render pass — see the | |
| * field's doc comment. */ | |
| getDeformedRotationsBuffer(): GPUBuffer { | |
| return this.deformedRotationsBuffer; | |
| } | |
| /** Renders exactly one frame. Exposed separately from the loop so tests | |
| * (and the No-Loop verification harness, T6) can drive frames | |
| * deterministically without waiting on real rAF timing. */ | |
| renderFrame() { | |
| const t0 = performance.now(); | |
| // Cheap on every frame (two integer reads + comparisons); only | |
| // reconfigures the GPU context on the frames where the canvas's | |
| // display size has actually changed (e.g. a window resize or the | |
| // split-pane divider being dragged). | |
| if (resizeCanvasToDisplaySize(this.canvas)) { | |
| this.context.configure({ device: this.device, format: this.format, alphaMode: "premultiplied" }); | |
| } | |
| if (!this.hasReceivedAnyFrame) { | |
| this.stats.framesWithNoDataYet += 1; | |
| } | |
| this.stats.frameAgeMs = this.hasReceivedAnyFrame | |
| ? performance.now() - this.lastFrameReceivedAtMs | |
| : 0; | |
| this.updateFaceUniforms(); | |
| this.updateCameraUniforms(); | |
| const encoder = this.device.createCommandEncoder({ label: "frameEncoder" }); | |
| const computePass = encoder.beginComputePass({ label: "deformPass" }); | |
| computePass.setPipeline(this.deformPipeline); | |
| computePass.setBindGroup(0, this.deformBindGroup); | |
| const workgroups = Math.ceil(this.splatData.count / 64); | |
| computePass.dispatchWorkgroups(workgroups); | |
| computePass.end(); | |
| const view = this.context.getCurrentTexture().createView(); | |
| const renderPass = encoder.beginRenderPass({ | |
| label: "splatRenderPass", | |
| colorAttachments: [ | |
| { | |
| view, | |
| clearValue: { r: 0.04, g: 0.04, b: 0.06, a: 1 }, | |
| loadOp: "clear", | |
| storeOp: "store", | |
| }, | |
| ], | |
| }); | |
| renderPass.setPipeline(this.renderPipeline); | |
| renderPass.setBindGroup(0, this.renderBindGroup); | |
| renderPass.draw(4, this.splatData.count); | |
| renderPass.end(); | |
| this.device.queue.submit([encoder.finish()]); | |
| this.stats.framesRendered += 1; | |
| this.stats.lastFrameTimeMs = performance.now() - t0; | |
| } | |
| private updateFaceUniforms() { | |
| const packed = new Float32Array(FACE_UNIFORMS_BYTES / 4); | |
| packed.set(this.currentWeights, 0); | |
| this.device.queue.writeBuffer(this.faceUniformBuffer, 0, packed); | |
| } | |
| private updateCameraUniforms() { | |
| const aspect = this.canvas.width / Math.max(1, this.canvas.height); | |
| const viewProj = buildSimpleViewProj(aspect); | |
| const buf = new Float32Array(CAMERA_UNIFORMS_BYTES / 4); | |
| buf.set(viewProj, 0); // 16 floats, mat4x4 | |
| buf.set([1, 0, 0, 0], 16); // cameraRight | |
| buf.set([0, 1, 0, 0], 20); // cameraUp | |
| this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, buf); | |
| } | |
| /** | |
| * Starts the decoupled render loop. Returns a stop function. | |
| * | |
| * This function is the entire mechanism satisfying the "render loop | |
| * must never freeze on network stall" guardrail: it calls | |
| * `requestAnimationFrame` unconditionally every frame and renders | |
| * whatever `currentWeights` currently holds, with no await, no message | |
| * wait, and no dependency on `setWeights` having been called recently. | |
| */ | |
| startRenderLoop(): () => void { | |
| this.stopped = false; | |
| const loop = () => { | |
| if (this.stopped) return; | |
| this.renderFrame(); | |
| this.rafHandle = requestAnimationFrame(loop); | |
| }; | |
| this.rafHandle = requestAnimationFrame(loop); | |
| return () => this.stop(); | |
| } | |
| stop() { | |
| this.stopped = true; | |
| if (this.rafHandle !== null) { | |
| cancelAnimationFrame(this.rafHandle); | |
| this.rafHandle = null; | |
| } | |
| } | |
| destroy() { | |
| this.stop(); | |
| this.device.destroy(); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Helpers | |
| // --------------------------------------------------------------------------- | |
| function createStorageBuffer(device: GPUDevice, data: Float32Array, label: string): GPUBuffer { | |
| const buffer = device.createBuffer({ | |
| label, | |
| size: alignTo(data.byteLength, 4), | |
| usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, | |
| }); | |
| device.queue.writeBuffer(buffer, 0, data); | |
| return buffer; | |
| } | |
| function alignTo(size: number, align: number): number { | |
| return Math.ceil(size / align) * align; | |
| } | |
| /** | |
| * Sets a canvas's backing-store width/height to match its actual displayed | |
| * (CSS) size, in device pixels, if they differ. Returns true if a resize | |
| * happened, so callers know whether they need to reconfigure the GPU | |
| * context (required whenever the canvas's drawing buffer size changes). | |
| */ | |
| function resizeCanvasToDisplaySize(canvas: HTMLCanvasElement): boolean { | |
| const dpr = window.devicePixelRatio || 1; | |
| const displayWidth = Math.max(1, Math.round(canvas.clientWidth * dpr)); | |
| const displayHeight = Math.max(1, Math.round(canvas.clientHeight * dpr)); | |
| if (canvas.width === displayWidth && canvas.height === displayHeight) { | |
| return false; | |
| } | |
| canvas.width = displayWidth; | |
| canvas.height = displayHeight; | |
| return true; | |
| } | |
| /** Expands a tightly-packed xyz Float32Array into vec4-per-element layout | |
| * (WGSL storage buffers of `array<vec4<f32>>` require 16-byte stride per | |
| * element even though only 12 bytes are meaningful — this is the standard | |
| * "vec3 in a buffer is really vec4" rule every WGSL/std430-derived layout | |
| * has, and skipping it is exactly the class of buffer-alignment mistake | |
| * this Work Order's Rule 5 guardrail calls out). `w` is a caller-supplied | |
| * fill value (1.0 for positions so they're valid homogeneous points, 0.0 | |
| * for scales where the 4th component is unused). */ | |
| function toVec4Buffer(xyz: Float32Array, count: number, w: number): Float32Array { | |
| const out = new Float32Array(count * 4); | |
| for (let i = 0; i < count; i += 1) { | |
| out[i * 4 + 0] = xyz[i * 3 + 0]; | |
| out[i * 4 + 1] = xyz[i * 3 + 1]; | |
| out[i * 4 + 2] = xyz[i * 3 + 2]; | |
| out[i * 4 + 3] = w; | |
| } | |
| return out; | |
| } | |
| function toVec4ColorBuffer(rgb: Float32Array, opacity: Float32Array, count: number): Float32Array { | |
| const out = new Float32Array(count * 4); | |
| for (let i = 0; i < count; i += 1) { | |
| out[i * 4 + 0] = rgb[i * 3 + 0]; | |
| out[i * 4 + 1] = rgb[i * 3 + 1]; | |
| out[i * 4 + 2] = rgb[i * 3 + 2]; | |
| out[i * 4 + 3] = opacity[i]; | |
| } | |
| return out; | |
| } | |
| /** | |
| * A deliberately simple fixed camera: looks at the origin from a small | |
| * positive-z offset, orthographic-ish perspective. Real camera controls | |
| * (orbit, zoom) are out of scope for T4's shader-correctness goal — the | |
| * placeholder splat only needs to be visible and deformable, not | |
| * cinematically framed. | |
| */ | |
| function buildSimpleViewProj(aspect: number): Float32Array { | |
| const fovY = 0.8; // radians | |
| const near = 0.05; | |
| const far = 10; | |
| const f = 1 / Math.tan(fovY / 2); | |
| // Column-major, matching WGSL's mat4x4<f32> column-major convention. | |
| const proj = new Float32Array([ | |
| f / aspect, 0, 0, 0, | |
| 0, f, 0, 0, | |
| 0, 0, far / (near - far), -1, | |
| 0, 0, (far * near) / (near - far), 0, | |
| ]); | |
| // Simple lookAt from (0, 0.05, 1.4) toward the origin, y-up. With the | |
| // camera on the +z axis looking toward -z, view = translate(-eye) since | |
| // no rotation is needed (forward is already -z, up is already +y). | |
| const eye = [0, 0.05, 1.4]; | |
| const view = new Float32Array([ | |
| 1, 0, 0, 0, | |
| 0, 1, 0, 0, | |
| 0, 0, 1, 0, | |
| -eye[0], -eye[1], -eye[2], 1, | |
| ]); | |
| return multiplyMat4(proj, view); | |
| } | |
| /** Column-major 4x4 matrix multiply: returns proj * view. */ | |
| function multiplyMat4(a: Float32Array, b: Float32Array): Float32Array { | |
| const out = new Float32Array(16); | |
| for (let col = 0; col < 4; col += 1) { | |
| for (let row = 0; row < 4; row += 1) { | |
| let sum = 0; | |
| for (let k = 0; k < 4; k += 1) { | |
| sum += a[k * 4 + row] * b[col * 4 + k]; | |
| } | |
| out[col * 4 + row] = sum; | |
| } | |
| } | |
| return out; | |
| } | |