feat: WebGL 3D model viewer with smooth vertex normals, water caustics, soft cyan-slate material, floaty controls, and camera position mapping

This commit is contained in:
2026-08-26 00:36:02 +02:00
parent 61df99e99c
commit b98231ae08
12 changed files with 2417 additions and 0 deletions
+518
View File
@@ -0,0 +1,518 @@
(() => {
const canvas = document.querySelector('#model-canvas');
const stage = document.querySelector('.rov-stage');
const renderScale = Math.max(2.0, window.devicePixelRatio || 2.0);
const gl = canvas.getContext('webgl', { alpha: true, antialias: true, preserveDrawingBuffer: false });
if (!gl) { window.rovModel = { loadBuffer() { throw new Error('WebGL is unavailable in this WebView'); } }; return; }
// Pass 1: Geometry shader - renders view-space normals and depth to FBO (Hero-sized 0.88 dynamic projection)
const vertexSceneSource = `
attribute vec3 aPosition;
attribute vec3 aNormal;
uniform float uYaw, uPitch, uZoom, uAspect;
varying vec3 vNormal;
varying vec3 vViewPos;
void main() {
float cy = cos(uYaw), sy = sin(uYaw);
vec3 p = vec3(aPosition.x * cy + aPosition.z * sy, aPosition.y, -aPosition.x * sy + aPosition.z * cy);
vec3 n = vec3(aNormal.x * cy + aNormal.z * sy, aNormal.y, -aNormal.x * sy + aNormal.z * cy);
float cp = cos(uPitch), sp = sin(uPitch);
p = vec3(p.x, p.y * cp - p.z * sp, p.y * sp + p.z * cp);
n = vec3(n.x, n.y * cp - n.z * sp, n.y * sp + n.z * cp);
// Base scale (1.15 fills 95-100% of canvas frame without over-zooming out)
float baseScale = 1.15 * uZoom;
// Dynamic pixel-perfect aspect projection: fills window canvas without distortion or shrinking
vec2 clipPos;
if (uAspect >= 1.0) {
clipPos = vec2(p.x * baseScale / uAspect, p.y * baseScale);
} else {
clipPos = vec2(p.x * baseScale, p.y * baseScale * uAspect);
}
gl_Position = vec4(clipPos.x, clipPos.y, p.z * baseScale * .4, 1.);
vViewPos = vec3(clipPos.x, clipPos.y, p.z * baseScale);
vNormal = normalize(n);
}
`;
const fragmentSceneSource = `
precision mediump float;
varying vec3 vNormal;
varying vec3 vViewPos;
void main() {
vec3 encNormal = normalize(vNormal) * .5 + .5;
float encDepth = clamp(vViewPos.z * .4 + .5, .01, .99);
gl_FragColor = vec4(encNormal, encDepth);
}
`;
// Pass 2: Quad shader - Vibrant Soft Cyan-Slate Material + Water Caustics + Argo Glare (Non-grey)
const vertexQuadSource = `
attribute vec2 aQuadPos;
varying vec2 vTexCoord;
void main() {
vTexCoord = aQuadPos * .5 + .5;
gl_Position = vec4(aQuadPos, 0., 1.);
}
`;
const fragmentQuadSource = `
precision mediump float;
uniform sampler2D uTex;
uniform vec2 uTexSize;
uniform float uTime;
varying vec2 vTexCoord;
// Animated water caustics shader
float waterCaustics(vec2 uv, float t) {
vec2 p = uv * 8.0;
vec2 i = vec2(p);
float c = 1.0;
float intent = 0.14;
for (int n = 0; n < 4; n++) {
float t_ang = t * (0.8 - (2.2 / float(n + 1)));
vec2 coord = i + vec2(cos(t_ang - i.x) + sin(t_ang + i.y), sin(t_ang - i.y) + cos(t_ang + i.x));
c += 1.0 / length(vec2(i.x / (sin(coord.x + t_ang) / intent), i.y / (cos(coord.y + t_ang) / intent)));
i = coord;
}
c /= 4.0;
c = 1.15 - sqrt(c);
return clamp(pow(max(0.0, c), 2.2) * 1.4, 0.0, 1.4);
}
void main() {
vec2 texel = 1. / uTexSize;
// Subpixel 4-sample Anti-Aliasing for smooth geometry boundaries
vec4 s0 = texture2D(uTex, vTexCoord + vec2(-0.25, -0.25) * texel);
vec4 s1 = texture2D(uTex, vTexCoord + vec2( 0.25, -0.25) * texel);
vec4 s2 = texture2D(uTex, vTexCoord + vec2(-0.25, 0.25) * texel);
vec4 s3 = texture2D(uTex, vTexCoord + vec2( 0.25, 0.25) * texel);
vec4 center = (s0 + s1 + s2 + s3) * 0.25;
float alphaCoverage = clamp((s0.a > .001 ? .25 : 0.) + (s1.a > .001 ? .25 : 0.) + (s2.a > .001 ? .25 : 0.) + (s3.a > .001 ? .25 : 0.), 0., 1.);
if (alphaCoverage <= .001) {
gl_FragColor = vec4(0., 0., 0., 0.);
return;
}
vec3 N = normalize(center.rgb * 2. - 1.);
float centerDepth = center.a;
// 1. Light Ambient Occlusion
float occlusion = 0.;
vec2 offsets[8];
offsets[0] = vec2(1.5, 0.); offsets[1] = vec2(-1.5, 0.);
offsets[2] = vec2(0., 1.5); offsets[3] = vec2(0., -1.5);
offsets[4] = vec2(1.1, 1.1); offsets[5] = vec2(-1.1, 1.1);
offsets[6] = vec2(1.1, -1.1); offsets[7] = vec2(-1.1, -1.1);
for (int i = 0; i < 8; i++) {
vec4 sampleVal = texture2D(uTex, vTexCoord + offsets[i] * texel);
if (sampleVal.a > .001) {
float dDiff = sampleVal.a - centerDepth;
if (dDiff < -.0005 && dDiff > -.12) {
occlusion += clamp(-dDiff * 14.0, 0., 1.);
}
}
}
float ao = 1. - clamp(occlusion / 8.0 * 0.36, 0., .20);
// 2. Soft Luminous Cyan-Slate Material Shading (Vibrant, non-grey)
vec3 lightDir = normalize(vec3(.35, .85, .45));
float diff = max(dot(N, lightDir), 0.0);
vec3 viewDir = vec3(0., 0., 1.);
vec3 halfDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(N, halfDir), 0.0), 28.0);
// Argo crisp luminous glare
vec3 argoGlare = vec3(0.98, 0.96, 0.92) * spec * 0.95;
// Soft rim light
float fresnel = pow(1. - max(dot(N, viewDir), 0.), 2.0);
vec3 rimLight = vec3(0.65, 0.82, 0.95) * fresnel * 0.35;
// Soft vibrant cyan-slate gradient (non-grey, soft luminous depth)
vec3 baseShadow = vec3(.22, .44, .58);
vec3 baseBody = vec3(.38, .62, .78);
vec3 baseTop = vec3(.88, .92, .96);
float lFactor = clamp(diff * 0.7 + (N.y * 0.3 + 0.3), 0.0, 1.0);
vec3 bodyColor = mix(baseShadow, baseBody, smoothstep(0.0, 0.5, lFactor));
bodyColor = mix(bodyColor, baseTop, smoothstep(0.5, 1.0, lFactor));
// 3. Luminous Water Caustics on top/front facing surfaces
float topFacing = clamp(N.y * 0.7 + 0.45, 0.0, 1.0);
float causticsVal = waterCaustics(vTexCoord + vec2(sin(uTime * 0.2) * 0.04, uTime * 0.025), uTime * 0.75) * topFacing;
vec3 causticColor = vec3(0.30, 0.85, 1.0) * causticsVal * 1.1;
// Combine soft diffuse, vibrant cyan-slate body, rim light, glare, caustics, and AO
vec3 rawColor = (bodyColor + rimLight + argoGlare + causticColor) * ao;
// Soft filmic highlight compression (preserves vibrant colors without white crushing)
vec3 finalColor = rawColor / (rawColor + vec3(0.18)) * 1.08;
gl_FragColor = vec4(finalColor, alphaCoverage);
}
`;
function compileShader(type, source) {
const s = gl.createShader(type);
gl.shaderSource(s, source);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
return s;
}
function createProgram(vSrc, fSrc) {
const p = gl.createProgram();
gl.attachShader(p, compileShader(gl.VERTEX_SHADER, vSrc));
gl.attachShader(p, compileShader(gl.FRAGMENT_SHADER, fSrc));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
return p;
}
const programScene = createProgram(vertexSceneSource, fragmentSceneSource);
const programQuad = createProgram(vertexQuadSource, fragmentQuadSource);
const locScene = {
aPosition: gl.getAttribLocation(programScene, 'aPosition'),
aNormal: gl.getAttribLocation(programScene, 'aNormal'),
uYaw: gl.getUniformLocation(programScene, 'uYaw'),
uPitch: gl.getUniformLocation(programScene, 'uPitch'),
uZoom: gl.getUniformLocation(programScene, 'uZoom'),
uAspect: gl.getUniformLocation(programScene, 'uAspect')
};
const locQuad = {
aQuadPos: gl.getAttribLocation(programQuad, 'aQuadPos'),
uTex: gl.getUniformLocation(programQuad, 'uTex'),
uTexSize: gl.getUniformLocation(programQuad, 'uTexSize'),
uTime: gl.getUniformLocation(programQuad, 'uTime')
};
const bufferScene = gl.createBuffer();
const bufferQuad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufferQuad);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
let fbo = null, fboTexture = null, fboDepth = null, fboWidth = 0, fboHeight = 0;
function updateFbo(w, h) {
if (fboWidth === w && fboHeight === h) return;
fboWidth = w; fboHeight = h;
if (fbo) { gl.deleteFramebuffer(fbo); gl.deleteTexture(fboTexture); gl.deleteRenderbuffer(fboDepth); }
fboTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, fboTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
fboDepth = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, fboDepth);
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, w, h);
fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, fboTexture, 0);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, fboDepth);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
let vertexCount = 0, frame = 0;
let yaw = 0, pitch = 0, zoom = 1;
let targetYaw = 0.68, targetPitch = 0.42, targetZoom = 1.0;
let yawVelocity = 0, pitchVelocity = 0;
let dragging = false, lastX = 0, lastY = 0;
let introAnimating = false, introStartTime = 0, startYaw = 0, startPitch = 0, startZoom = 1;
// Live joystick & vertical knob control inputs
let liveTurn = 0, liveForward = 0, liveVertical = 0;
let targetLiveTurn = 0, targetLiveForward = 0, targetLiveVertical = 0;
const timeStart = performance.now();
function normalize(data) {
let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i < data.length; i += 6) {
const x = data[i], y = data[i + 1], z = data[i + 2];
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
minY = Math.min(minY, y); maxY = Math.max(maxY, y);
minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z);
}
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2, cz = (minZ + maxZ) / 2;
const scale = 2 / Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1e-9);
for (let i = 0; i < data.length; i += 6) {
data[i] = (data[i] - cx) * scale;
data[i + 1] = (data[i + 1] - cy) * scale;
data[i + 2] = (data[i + 2] - cz) * scale;
}
return data;
}
// Smooth vertex normal computation (welds shared vertices to eliminate sharp polygon facet lines)
function parseStl(binary) {
const view = new DataView(binary), count = binary.byteLength >= 84 ? view.getUint32(80, true) : 0;
let rawTriangles = [];
if (count > 0 && 84 + count * 50 <= binary.byteLength) {
for (let t = 0; t < count; t++) {
const base = 84 + t * 50;
const v0x = view.getFloat32(base + 12, true), v0y = view.getFloat32(base + 16, true), v0z = view.getFloat32(base + 20, true);
const v1x = view.getFloat32(base + 24, true), v1y = view.getFloat32(base + 28, true), v1z = view.getFloat32(base + 32, true);
const v2x = view.getFloat32(base + 36, true), v2y = view.getFloat32(base + 40, true), v2z = view.getFloat32(base + 44, true);
rawTriangles.push(v0x, v0y, v0z, v1x, v1y, v1z, v2x, v2y, v2z);
}
} else {
const text = new TextDecoder().decode(binary), matches = [...text.matchAll(/vertex\s+([-+\deE.]+)\s+([-+\deE.]+)\s+([-+\deE.]+)/gi)];
const triCount = Math.floor(matches.length / 3);
if (triCount < 1) throw new Error('This file does not contain a readable STL mesh');
for (let i = 0; i + 2 < matches.length; i += 3) {
rawTriangles.push(
+matches[i][1], +matches[i][2], +matches[i][3],
+matches[i+1][1], +matches[i+1][2], +matches[i+1][3],
+matches[i+2][1], +matches[i+2][2], +matches[i+2][3]
);
}
}
const numTriangles = Math.floor(rawTriangles.length / 9);
const posMap = new Map();
const getVertexIndex = (x, y, z) => {
const key = `${Math.round(x * 10000)},${Math.round(y * 10000)},${Math.round(z * 10000)}`;
let idx = posMap.get(key);
if (idx === undefined) {
idx = posMap.size;
posMap.set(key, idx);
}
return idx;
};
// First pass: Index vertices and accumulate smooth face normals
const triangleIndices = new Int32Array(numTriangles * 3);
for (let t = 0; t < numTriangles; t++) {
const b = t * 9;
triangleIndices[t * 3] = getVertexIndex(rawTriangles[b], rawTriangles[b+1], rawTriangles[b+2]);
triangleIndices[t * 3 + 1] = getVertexIndex(rawTriangles[b+3], rawTriangles[b+4], rawTriangles[b+5]);
triangleIndices[t * 3 + 2] = getVertexIndex(rawTriangles[b+6], rawTriangles[b+7], rawTriangles[b+8]);
}
const numUnique = posMap.size;
const accumNormals = new Float32Array(numUnique * 3);
for (let t = 0; t < numTriangles; t++) {
const b = t * 9;
const v0x = rawTriangles[b], v0y = rawTriangles[b+1], v0z = rawTriangles[b+2];
const v1x = rawTriangles[b+3], v1y = rawTriangles[b+4], v1z = rawTriangles[b+5];
const v2x = rawTriangles[b+6], v2y = rawTriangles[b+7], v2z = rawTriangles[b+8];
const ax = v1x - v0x, ay = v1y - v0y, az = v1z - v0z;
const bx = v2x - v0x, by = v2y - v0y, bz = v2z - v0z;
const nx = ay * bz - az * by, ny = az * bx - ax * bz, nz = ax * by - ay * bx;
const i0 = triangleIndices[t * 3], i1 = triangleIndices[t * 3 + 1], i2 = triangleIndices[t * 3 + 2];
accumNormals[i0 * 3] += nx; accumNormals[i0 * 3 + 1] += ny; accumNormals[i0 * 3 + 2] += nz;
accumNormals[i1 * 3] += nx; accumNormals[i1 * 3 + 1] += ny; accumNormals[i1 * 3 + 2] += nz;
accumNormals[i2 * 3] += nx; accumNormals[i2 * 3 + 1] += ny; accumNormals[i2 * 3 + 2] += nz;
}
// Second pass: Normalize smooth vertex normals
for (let i = 0; i < numUnique; i++) {
const b = i * 3;
const len = Math.hypot(accumNormals[b], accumNormals[b+1], accumNormals[b+2]) || 1;
accumNormals[b] /= len; accumNormals[b+1] /= len; accumNormals[b+2] /= len;
}
// Third pass: Assemble final vertex array (6 floats per vertex)
const data = new Float32Array(numTriangles * 18);
let out = 0;
for (let t = 0; t < numTriangles; t++) {
const b = t * 9;
for (let v = 0; v < 3; v++) {
const vx = rawTriangles[b + v * 3], vy = rawTriangles[b + v * 3 + 1], vz = rawTriangles[b + v * 3 + 2];
const idx = triangleIndices[t * 3 + v];
data[out++] = vx; data[out++] = vy; data[out++] = vz;
data[out++] = accumNormals[idx * 3];
data[out++] = accumNormals[idx * 3 + 1];
data[out++] = accumNormals[idx * 3 + 2];
}
}
return normalize(data);
}
function draw() {
frame = 0;
if (!vertexCount) return;
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, Math.round(rect.width * renderScale));
const height = Math.max(1, Math.round(rect.height * renderScale));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
updateFbo(width, height);
const now = performance.now();
const currentTime = (now - timeStart) * 0.001;
// Interpolate live control inputs (turn, forward, vertical)
liveTurn += (targetLiveTurn - liveTurn) * 0.15;
liveForward += (targetLiveForward - liveForward) * 0.15;
liveVertical += (targetLiveVertical - liveVertical) * 0.15;
// Handle auto-center 3/4 hero camera transition & floaty physics
if (introAnimating && !dragging) {
const elapsed = (now - introStartTime) / 1200;
if (elapsed >= 1.0) {
introAnimating = false;
yaw = targetYaw; pitch = targetPitch; zoom = targetZoom;
} else {
const t = 1 - Math.pow(1 - elapsed, 3);
yaw = startYaw + (targetYaw - startYaw) * t;
pitch = startPitch + (targetPitch - startPitch) * t;
zoom = startZoom + (targetZoom - startZoom) * t;
}
} else {
yaw += yawVelocity;
pitch = Math.max(-1.45, Math.min(1.45, pitch + pitchVelocity));
zoom += (targetZoom - zoom) * 0.1;
yawVelocity *= 0.92;
pitchVelocity *= 0.92;
}
// Effective yaw & pitch including live joystick/heave control offsets
const effectiveYaw = yaw + liveTurn * 0.7;
const effectivePitch = Math.max(-1.45, Math.min(1.45, pitch + (liveVertical * 0.65 + liveForward * 0.4)));
// Pass 1: Render Normals & Linear Depth to FBO
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.viewport(0, 0, width, height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
gl.useProgram(programScene);
gl.bindBuffer(gl.ARRAY_BUFFER, bufferScene);
gl.enableVertexAttribArray(locScene.aPosition);
gl.vertexAttribPointer(locScene.aPosition, 3, gl.FLOAT, false, 24, 0);
gl.enableVertexAttribArray(locScene.aNormal);
gl.vertexAttribPointer(locScene.aNormal, 3, gl.FLOAT, false, 24, 12);
gl.uniform1f(locScene.uYaw, effectiveYaw);
gl.uniform1f(locScene.uPitch, effectivePitch);
gl.uniform1f(locScene.uZoom, zoom);
gl.uniform1f(locScene.uAspect, width / height);
gl.drawArrays(gl.TRIANGLES, 0, vertexCount);
// Pass 2: Soft "Eve"-style Material Pass to Canvas
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, width, height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.disable(gl.DEPTH_TEST);
gl.useProgram(programQuad);
gl.bindBuffer(gl.ARRAY_BUFFER, bufferQuad);
gl.enableVertexAttribArray(locQuad.aQuadPos);
gl.vertexAttribPointer(locQuad.aQuadPos, 2, gl.FLOAT, false, 0, 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, fboTexture);
gl.uniform1i(locQuad.uTex, 0);
gl.uniform2f(locQuad.uTexSize, width, height);
gl.uniform1f(locQuad.uTime, currentTime);
gl.drawArrays(gl.TRIANGLES, 0, 6);
// Keep animation active if floaty velocity or control interpolation is active
if (Math.abs(yawVelocity) > 1e-4 || Math.abs(pitchVelocity) > 1e-4 ||
Math.abs(targetLiveTurn - liveTurn) > 1e-3 || Math.abs(targetLiveVertical - liveVertical) > 1e-3 ||
introAnimating) {
requestDraw();
}
}
function requestDraw() {
if (!frame) frame = requestAnimationFrame(draw);
}
canvas.addEventListener('pointerdown', event => {
dragging = true;
introAnimating = false;
lastX = event.clientX;
lastY = event.clientY;
yawVelocity = 0;
pitchVelocity = 0;
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener('pointermove', event => {
if (!dragging) return;
const dx = event.clientX - lastX;
const dy = event.clientY - lastY;
yawVelocity = dx * 0.0012;
pitchVelocity = dy * 0.0012;
yaw += yawVelocity;
pitch = Math.max(-1.45, Math.min(1.45, pitch + pitchVelocity));
lastX = event.clientX;
lastY = event.clientY;
requestDraw();
});
canvas.addEventListener('pointerup', () => dragging = false);
canvas.addEventListener('pointercancel', () => dragging = false);
canvas.addEventListener('wheel', event => {
event.preventDefault();
introAnimating = false;
targetZoom = Math.max(.55, Math.min(2.2, targetZoom * (event.deltaY > 0 ? .9 : 1.1)));
requestDraw();
}, { passive: false });
addEventListener('resize', requestDraw);
window.rovModel = {
setLiveControl(turn, forward, vertical) {
targetLiveTurn = turn || 0;
targetLiveForward = forward || 0;
targetLiveVertical = vertical || 0;
requestDraw();
},
loadBuffer(data, name) {
const vertices = parseStl(data);
vertexCount = vertices.length / 6;
gl.bindBuffer(gl.ARRAY_BUFFER, bufferScene);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
stage.classList.add('model-loaded');
document.querySelector('#model-file-name').textContent = name;
// Auto-center camera transition to 3/4 corner hero shot upon load
yaw = 0;
pitch = 0;
zoom = 0.75;
targetYaw = 0.68;
targetPitch = 0.42;
targetZoom = 1.0;
yawVelocity = 0;
pitchVelocity = 0;
setTimeout(() => {
introAnimating = true;
introStartTime = performance.now();
startYaw = yaw;
startPitch = pitch;
startZoom = zoom;
requestDraw();
}, 150);
requestDraw();
return { name, triangles: vertexCount / 3 };
}
};
})();