(() => { 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 ix = (x * 2000) | 0, iy = (y * 2000) | 0, iz = (z * 2000) | 0; const key = (ix * 73856093 ^ iy * 19349663 ^ iz * 83492791) >>> 0; 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(); if (rect.width < 10 || rect.height < 10) { // Container layout is not ready yet; retry on next frame requestDraw(); return; } 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; // Defer rendering to post-reflow rAF so canvas dimensions are ready requestAnimationFrame(() => { requestDraw(); setTimeout(() => { introAnimating = true; introStartTime = performance.now(); startYaw = yaw; startPitch = pitch; startZoom = zoom; requestDraw(); }, 150); }); requestDraw(); return { name, triangles: vertexCount / 3 }; } }; })();