|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | + <meta charset="UTF-8" /> |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 6 | + <title>RuVector VWM - 4D Gaussian Splatting Viewer (Canvas2D)</title> |
| 7 | + <style> |
| 8 | + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| 9 | + html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a0f; color: #e0e0e8; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; } |
| 10 | + canvas { display: block; width: 100%; height: 100%; } |
| 11 | + #overlay { position: fixed; inset: 0; pointer-events: none; z-index: 10; } |
| 12 | + #overlay > * { pointer-events: auto; } |
| 13 | + #stats-panel { |
| 14 | + position: absolute; top: 12px; left: 12px; |
| 15 | + background: rgba(10,10,20,0.85); backdrop-filter: blur(6px); |
| 16 | + border: 1px solid rgba(100,120,255,0.25); border-radius: 8px; |
| 17 | + padding: 10px 14px; font-size: 0.75rem; line-height: 1.7; min-width: 200px; |
| 18 | + } |
| 19 | + .label { color: #888; } .value { color: #a0c4ff; font-weight: 600; } |
| 20 | + .coherence-badge { |
| 21 | + position: absolute; top: 12px; right: 12px; |
| 22 | + background: rgba(10,10,20,0.85); backdrop-filter: blur(6px); |
| 23 | + border: 1px solid rgba(100,120,255,0.25); border-radius: 8px; |
| 24 | + padding: 6px 14px; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; |
| 25 | + } |
| 26 | + .coherence-badge.coherent { border-color: #4ade80; color: #4ade80; } |
| 27 | + .coherence-badge.degraded { border-color: #facc15; color: #facc15; } |
| 28 | + #transport-bar { |
| 29 | + position: absolute; bottom: 0; left: 0; right: 0; |
| 30 | + background: rgba(10,10,20,0.9); backdrop-filter: blur(6px); |
| 31 | + border-top: 1px solid rgba(100,120,255,0.2); |
| 32 | + display: flex; align-items: center; gap: 12px; padding: 8px 16px; font-size: 0.75rem; |
| 33 | + } |
| 34 | + #play-btn { |
| 35 | + background: rgba(100,120,255,0.2); border: 1px solid rgba(100,120,255,0.4); |
| 36 | + color: #a0c4ff; border-radius: 4px; padding: 4px 12px; cursor: pointer; |
| 37 | + font-family: inherit; font-size: 0.75rem; |
| 38 | + } |
| 39 | + #play-btn:hover { background: rgba(100,120,255,0.35); } |
| 40 | + #time-slider { flex: 1; accent-color: #6478ff; height: 4px; } |
| 41 | + #time-label { color: #888; min-width: 5rem; text-align: right; } |
| 42 | + #search-box { |
| 43 | + position: absolute; top: 12px; left: 50%; transform: translateX(-50%); |
| 44 | + background: rgba(10,10,20,0.85); backdrop-filter: blur(6px); |
| 45 | + border: 1px solid rgba(100,120,255,0.25); border-radius: 8px; |
| 46 | + padding: 6px 14px; color: #e0e0e8; font-family: inherit; font-size: 0.75rem; |
| 47 | + width: 240px; outline: none; |
| 48 | + } |
| 49 | + #search-box::placeholder { color: #555; } |
| 50 | + #search-box:focus { border-color: rgba(100,120,255,0.6); } |
| 51 | + #status-text { position: absolute; bottom: 44px; left: 16px; font-size: 0.65rem; color: #555; } |
| 52 | + #legend { |
| 53 | + position: absolute; bottom: 52px; right: 12px; |
| 54 | + background: rgba(10,10,20,0.85); backdrop-filter: blur(6px); |
| 55 | + border: 1px solid rgba(100,120,255,0.15); border-radius: 8px; |
| 56 | + padding: 8px 12px; font-size: 0.65rem; line-height: 1.8; |
| 57 | + } |
| 58 | + .legend-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; } |
| 59 | + </style> |
| 60 | +</head> |
| 61 | +<body> |
| 62 | + <canvas id="viewport"></canvas> |
| 63 | + <div id="overlay"> |
| 64 | + <div id="stats-panel"> |
| 65 | + <div><span class="label">FPS </span><span class="value" id="fps-value">--</span></div> |
| 66 | + <div><span class="label">Gaussians </span><span class="value" id="gaussian-count">0</span></div> |
| 67 | + <div><span class="label">Visible </span><span class="value" id="visible-count">0</span></div> |
| 68 | + <div><span class="label">Mode </span><span class="value">canvas2d</span></div> |
| 69 | + <div><span class="label">Entities </span><span class="value" id="entity-count">5</span></div> |
| 70 | + </div> |
| 71 | + <div id="coherence-state" class="coherence-badge coherent">coherent</div> |
| 72 | + <input id="search-box" type="text" placeholder="Search: background, planet, shuttle, core..." /> |
| 73 | + <div id="status-text">initializing...</div> |
| 74 | + <div id="legend"> |
| 75 | + <div><span class="legend-dot" style="background:#4466cc"></span>background (60%)</div> |
| 76 | + <div><span class="legend-dot" style="background:#ee6633"></span>planet-alpha (15%)</div> |
| 77 | + <div><span class="legend-dot" style="background:#44cc66"></span>planet-beta (10%)</div> |
| 78 | + <div><span class="legend-dot" style="background:#eeee55"></span>shuttle (5%)</div> |
| 79 | + <div><span class="legend-dot" style="background:#ee88ff"></span>core (10%)</div> |
| 80 | + </div> |
| 81 | + <div id="transport-bar"> |
| 82 | + <button id="play-btn">Pause</button> |
| 83 | + <input id="time-slider" type="range" min="0" max="1000" value="0" /> |
| 84 | + <span id="time-label">t=0.000</span> |
| 85 | + </div> |
| 86 | + </div> |
| 87 | + |
| 88 | + <script> |
| 89 | + // ---- Demo Data Generator (same algorithm as demo-data.js) ---- |
| 90 | + function rand(a, b) { return Math.random() * (b - a) + a; } |
| 91 | + |
| 92 | + function generateGaussians(count, timeSteps) { |
| 93 | + const gs = [], labels = []; |
| 94 | + const bgN = Math.floor(count * 0.6); |
| 95 | + for (let i = 0; i < bgN; i++) { |
| 96 | + const r = rand(2, 12), th = rand(0, Math.PI*2), ph = rand(-Math.PI/2, Math.PI/2); |
| 97 | + gs.push({ positions: [[r*Math.cos(ph)*Math.sin(th), r*Math.sin(ph), r*Math.cos(ph)*Math.cos(th)]], |
| 98 | + color: [rand(0.15,0.35), rand(0.15,0.35), rand(0.4,0.7)], opacity: rand(0.3,0.7), |
| 99 | + scale: [rand(0.05,0.15), rand(0.05,0.15), rand(0.05,0.15)] }); |
| 100 | + labels.push('background'); |
| 101 | + } |
| 102 | + function orbit(n, radius, colorFn, label) { |
| 103 | + for (let i = 0; i < n; i++) { |
| 104 | + const ox=rand(-0.4,0.4), oy=rand(-0.4,0.4), oz=rand(-0.4,0.4), positions=[]; |
| 105 | + for (let t=0; t<timeSteps; t++) { |
| 106 | + const a = (t/timeSteps)*Math.PI*2; |
| 107 | + positions.push([radius*Math.sin(a)+ox, Math.sin(a*2)*0.5+oy, radius*Math.cos(a)+oz]); |
| 108 | + } |
| 109 | + gs.push({ positions, color: colorFn(), opacity: rand(0.6,0.95), scale: [rand(0.08,0.2),rand(0.08,0.2),rand(0.08,0.2)] }); |
| 110 | + labels.push(label); |
| 111 | + } |
| 112 | + } |
| 113 | + orbit(Math.floor(count*0.15), 3.0, ()=>[rand(0.8,1),rand(0.3,0.5),rand(0.1,0.3)], 'planet-alpha'); |
| 114 | + orbit(Math.floor(count*0.1), 5.0, ()=>[rand(0.2,0.4),rand(0.8,1),rand(0.3,0.5)], 'planet-beta'); |
| 115 | + const shN = Math.floor(count*0.05); |
| 116 | + for (let i=0; i<shN; i++) { |
| 117 | + const ox=rand(-0.2,0.2), oy=rand(-0.2,0.2), oz=rand(-0.2,0.2), positions=[]; |
| 118 | + for (let t=0; t<timeSteps; t++) { |
| 119 | + const f=t/timeSteps, cx=(f<0.5?f*2-0.5:1.5-f*2)*10; |
| 120 | + positions.push([cx+ox, 2+oy, oz]); |
| 121 | + } |
| 122 | + gs.push({ positions, color: [rand(0.9,1),rand(0.9,1),rand(0.3,0.5)], opacity: rand(0.7,1), scale: [rand(0.05,0.12),rand(0.05,0.12),rand(0.15,0.3)] }); |
| 123 | + labels.push('shuttle'); |
| 124 | + } |
| 125 | + const coreN = count - bgN - Math.floor(count*0.15) - Math.floor(count*0.1) - shN; |
| 126 | + for (let i=0; i<coreN; i++) { |
| 127 | + const a=(i/coreN)*Math.PI*2, br=rand(0.2,0.6), x=br*Math.cos(a), z=br*Math.sin(a), positions=[]; |
| 128 | + for (let t=0; t<timeSteps; t++) { |
| 129 | + const p=1+0.3*Math.sin((t/timeSteps)*Math.PI*4); |
| 130 | + positions.push([x*p, rand(-0.1,0.1), z*p]); |
| 131 | + } |
| 132 | + gs.push({ positions, color: [rand(0.9,1),rand(0.5,0.7),rand(0.8,1)], opacity: rand(0.7,1), scale: [rand(0.1,0.25),rand(0.1,0.25),rand(0.1,0.25)] }); |
| 133 | + labels.push('core'); |
| 134 | + } |
| 135 | + return { gaussians: gs, labels, timeSteps }; |
| 136 | + } |
| 137 | + |
| 138 | + function samplePos(g, t) { |
| 139 | + const p = g.positions; |
| 140 | + if (p.length === 1) return p[0]; |
| 141 | + const ft = t*(p.length-1), i0 = Math.floor(ft), i1 = Math.min(i0+1,p.length-1), f = ft-i0; |
| 142 | + return [p[i0][0]+(p[i1][0]-p[i0][0])*f, p[i0][1]+(p[i1][1]-p[i0][1])*f, p[i0][2]+(p[i1][2]-p[i0][2])*f]; |
| 143 | + } |
| 144 | + |
| 145 | + // ---- Camera (orbit) ---- |
| 146 | + class Camera { |
| 147 | + constructor() { |
| 148 | + this.theta = 0.3; this.phi = 0.3; this.radius = 14; |
| 149 | + this.target = [0,0,0]; this.fov = Math.PI/4; |
| 150 | + } |
| 151 | + eye() { |
| 152 | + return [ |
| 153 | + this.target[0]+this.radius*Math.cos(this.phi)*Math.sin(this.theta), |
| 154 | + this.target[1]+this.radius*Math.sin(this.phi), |
| 155 | + this.target[2]+this.radius*Math.cos(this.phi)*Math.cos(this.theta) |
| 156 | + ]; |
| 157 | + } |
| 158 | + viewProj(w, h) { |
| 159 | + // Build view matrix |
| 160 | + const e = this.eye(), t = this.target; |
| 161 | + let fx=e[0]-t[0], fy=e[1]-t[1], fz=e[2]-t[2]; |
| 162 | + let l=Math.sqrt(fx*fx+fy*fy+fz*fz); fx/=l; fy/=l; fz/=l; |
| 163 | + let rx=fy*0-0*fz, ry=0*fx-1*fz, rz=1*fy-fy*fx; // up=[0,1,0] x forward... simplified |
| 164 | + // Proper cross: up x forward |
| 165 | + rx = 1*fz - 0*fy; ry = 0*fx - 0*fz; rz = 0*fy - 1*fx; |
| 166 | + // Actually: up=[0,1,0], cross(up, f) = [up.y*fz - up.z*fy, up.z*fx - up.x*fz, up.x*fy - up.y*fx] |
| 167 | + rx = fz; ry = 0; rz = -fx; |
| 168 | + l=Math.sqrt(rx*rx+ry*ry+rz*rz); if(l>1e-6){rx/=l; ry/=l; rz/=l;} |
| 169 | + const ux=fy*rz-fz*ry, uy=fz*rx-fx*rz, uz=fx*ry-fy*rx; |
| 170 | + const V = [ |
| 171 | + rx,ux,fx,0, ry,uy,fy,0, rz,uz,fz,0, |
| 172 | + -(rx*e[0]+ry*e[1]+rz*e[2]), -(ux*e[0]+uy*e[1]+uz*e[2]), -(fx*e[0]+fy*e[1]+fz*e[2]), 1 |
| 173 | + ]; |
| 174 | + // Build projection |
| 175 | + const f = 1/Math.tan(this.fov/2), a = w/h, ri = 1/(0.1-200); |
| 176 | + const P = [f/a,0,0,0, 0,f,0,0, 0,0,200*ri,-1, 0,0,200*0.1*ri,0]; |
| 177 | + // Multiply P * V |
| 178 | + const M = new Array(16); |
| 179 | + for (let i=0;i<4;i++) for (let j=0;j<4;j++) { |
| 180 | + M[j*4+i] = P[0*4+i]*V[j*4+0]+P[1*4+i]*V[j*4+1]+P[2*4+i]*V[j*4+2]+P[3*4+i]*V[j*4+3]; |
| 181 | + } |
| 182 | + return M; |
| 183 | + } |
| 184 | + } |
| 185 | + |
| 186 | + // ---- Main ---- |
| 187 | + const canvas = document.getElementById('viewport'); |
| 188 | + const ctx = canvas.getContext('2d'); |
| 189 | + const cam = new Camera(); |
| 190 | + const COUNT = 2000, STEPS = 120; |
| 191 | + const demo = generateGaussians(COUNT, STEPS); |
| 192 | + const activeMask = new Array(COUNT).fill(true); |
| 193 | + let animTime = 0, playing = true; |
| 194 | + let searchQuery = ''; |
| 195 | + |
| 196 | + // Coherence simulation |
| 197 | + const coherenceStates = ['coherent','coherent','coherent','degraded','coherent']; |
| 198 | + let coherenceIdx = 0, coherenceTimer = 0; |
| 199 | + |
| 200 | + // DOM refs |
| 201 | + const fpsEl = document.getElementById('fps-value'); |
| 202 | + const gcEl = document.getElementById('gaussian-count'); |
| 203 | + const vcEl = document.getElementById('visible-count'); |
| 204 | + const cohEl = document.getElementById('coherence-state'); |
| 205 | + const statusEl = document.getElementById('status-text'); |
| 206 | + const slider = document.getElementById('time-slider'); |
| 207 | + const timeLabel = document.getElementById('time-label'); |
| 208 | + |
| 209 | + gcEl.textContent = COUNT.toLocaleString(); |
| 210 | + statusEl.textContent = `Demo: ${COUNT} gaussians, 5 entity groups, ${STEPS} time steps`; |
| 211 | + |
| 212 | + // Mouse orbit |
| 213 | + let dragging = false, lx = 0, ly = 0; |
| 214 | + canvas.addEventListener('mousedown', e => { dragging=true; lx=e.clientX; ly=e.clientY; }); |
| 215 | + window.addEventListener('mouseup', () => dragging=false); |
| 216 | + window.addEventListener('mousemove', e => { |
| 217 | + if (!dragging) return; |
| 218 | + cam.theta -= (e.clientX-lx)*0.005; cam.phi += (e.clientY-ly)*0.005; |
| 219 | + cam.phi = Math.max(-1.5,Math.min(1.5,cam.phi)); |
| 220 | + lx=e.clientX; ly=e.clientY; |
| 221 | + }); |
| 222 | + canvas.addEventListener('wheel', e => { e.preventDefault(); cam.radius *= 1+e.deltaY*0.001; cam.radius = Math.max(1,Math.min(50,cam.radius)); }, {passive:false}); |
| 223 | + |
| 224 | + // UI |
| 225 | + document.getElementById('play-btn').addEventListener('click', () => { |
| 226 | + playing = !playing; |
| 227 | + document.getElementById('play-btn').textContent = playing ? 'Pause' : 'Play'; |
| 228 | + }); |
| 229 | + slider.addEventListener('input', () => { |
| 230 | + animTime = parseInt(slider.value)/1000; playing = false; |
| 231 | + document.getElementById('play-btn').textContent = 'Play'; |
| 232 | + timeLabel.textContent = `t=${animTime.toFixed(3)}`; |
| 233 | + }); |
| 234 | + document.getElementById('search-box').addEventListener('input', e => { |
| 235 | + searchQuery = e.target.value.trim().toLowerCase(); |
| 236 | + for (let i = 0; i < COUNT; i++) activeMask[i] = !searchQuery || demo.labels[i].includes(searchQuery); |
| 237 | + }); |
| 238 | + |
| 239 | + // FPS |
| 240 | + const frameTimes = []; |
| 241 | + |
| 242 | + // Render loop |
| 243 | + let lastT = performance.now(); |
| 244 | + function frame(now) { |
| 245 | + requestAnimationFrame(frame); |
| 246 | + const dt = (now - lastT)/1000; lastT = now; |
| 247 | + |
| 248 | + // Canvas resize |
| 249 | + const dpr = window.devicePixelRatio || 1; |
| 250 | + const cw = Math.floor(canvas.clientWidth * dpr), ch = Math.floor(canvas.clientHeight * dpr); |
| 251 | + if (canvas.width !== cw || canvas.height !== ch) { canvas.width = cw; canvas.height = ch; } |
| 252 | + |
| 253 | + // Animation |
| 254 | + if (playing) { animTime = (animTime + dt * 0.15) % 1; slider.value = Math.round(animTime*1000); timeLabel.textContent = `t=${animTime.toFixed(3)}`; } |
| 255 | + |
| 256 | + // Coherence |
| 257 | + coherenceTimer += dt; |
| 258 | + if (coherenceTimer > 5) { coherenceTimer=0; coherenceIdx=(coherenceIdx+1)%coherenceStates.length; |
| 259 | + const s=coherenceStates[coherenceIdx]; cohEl.textContent=s; cohEl.className='coherence-badge '+s; } |
| 260 | + |
| 261 | + // Project & sort |
| 262 | + const W = canvas.width, H = canvas.height; |
| 263 | + const vp = cam.viewProj(W, H); |
| 264 | + const focal = H / (2 * Math.tan(cam.fov/2)); |
| 265 | + const projected = []; |
| 266 | + |
| 267 | + for (let i = 0; i < COUNT; i++) { |
| 268 | + if (!activeMask[i]) continue; |
| 269 | + const g = demo.gaussians[i]; |
| 270 | + const [wx,wy,wz] = samplePos(g, animTime); |
| 271 | + const cx=vp[0]*wx+vp[4]*wy+vp[8]*wz+vp[12]; |
| 272 | + const cy=vp[1]*wx+vp[5]*wy+vp[9]*wz+vp[13]; |
| 273 | + const cz=vp[2]*wx+vp[6]*wy+vp[10]*wz+vp[14]; |
| 274 | + const cw2=vp[3]*wx+vp[7]*wy+vp[11]*wz+vp[15]; |
| 275 | + if (cw2 <= 0.001) continue; |
| 276 | + const nx=cx/cw2, ny=cy/cw2; |
| 277 | + const sx=(nx*0.5+0.5)*W, sy=(1-(ny*0.5+0.5))*H; |
| 278 | + if (sx<-100||sx>W+100||sy<-100||sy>H+100) continue; |
| 279 | + const avgS = (g.scale[0]+g.scale[1]+g.scale[2])/3; |
| 280 | + const pr = (focal*avgS)/cw2; |
| 281 | + if (pr < 0.3) continue; |
| 282 | + const sigma = Math.max(pr*0.5, 0.5); |
| 283 | + projected.push({ sx, sy, depth: cz/cw2, sigma, r: g.color[0], g: g.color[1], b: g.color[2], opacity: g.opacity }); |
| 284 | + } |
| 285 | + |
| 286 | + // Sort back to front |
| 287 | + projected.sort((a,b) => b.depth - a.depth); |
| 288 | + |
| 289 | + // Draw |
| 290 | + ctx.clearRect(0, 0, W, H); |
| 291 | + ctx.fillStyle = '#0a0a0f'; |
| 292 | + ctx.fillRect(0, 0, W, H); |
| 293 | + |
| 294 | + for (const p of projected) { |
| 295 | + const rad = Math.min(p.sigma * 3, 300); |
| 296 | + if (rad < 0.5) continue; |
| 297 | + const gradient = ctx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, rad); |
| 298 | + const r = Math.round(p.r*255), g = Math.round(p.g*255), b = Math.round(p.b*255); |
| 299 | + gradient.addColorStop(0, `rgba(${r},${g},${b},${p.opacity})`); |
| 300 | + gradient.addColorStop(0.5, `rgba(${r},${g},${b},${p.opacity*0.3})`); |
| 301 | + gradient.addColorStop(1, `rgba(${r},${g},${b},0)`); |
| 302 | + ctx.fillStyle = gradient; |
| 303 | + ctx.fillRect(p.sx - rad, p.sy - rad, rad*2, rad*2); |
| 304 | + } |
| 305 | + |
| 306 | + // FPS |
| 307 | + frameTimes.push(now); |
| 308 | + if (frameTimes.length > 60) frameTimes.shift(); |
| 309 | + if (frameTimes.length > 1) { |
| 310 | + const fps = 1000 / ((frameTimes[frameTimes.length-1]-frameTimes[0])/(frameTimes.length-1)); |
| 311 | + fpsEl.textContent = fps.toFixed(1); |
| 312 | + } |
| 313 | + vcEl.textContent = projected.length.toLocaleString(); |
| 314 | + } |
| 315 | + requestAnimationFrame(frame); |
| 316 | + </script> |
| 317 | +</body> |
| 318 | +</html> |
0 commit comments