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:
@@ -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 };
|
||||
}
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user