diff --git a/public/preview.html b/public/preview.html index 0cf9e65..6feec52 100644 --- a/public/preview.html +++ b/public/preview.html @@ -246,31 +246,25 @@ const pr = vid.play(); if (pr && pr.catch) pr.catch(() => {}); group.userData.vidEl = vid; } else if (image) { - // Animated WebP (the iOS/WebKit fallback). Three.js won't advance an - // animated-image Texture on iOS, so instead we draw the into an - // offscreen canvas every frame and use a CanvasTexture. The browser keeps - // animating the WebP internally; drawImage() samples the current frame, so - // the texture animates and transparency is preserved. - const img = document.createElement('img'); - img.crossOrigin = 'anonymous'; img.src = image; - // iOS pauses animation on a detached , so attach it hidden off-screen - // to keep the WebP advancing. We sample its current frame into the canvas. - img.style.cssText = 'position:fixed;left:-99999px;top:0;width:64px;height:64px;pointer-events:none;opacity:0.01;'; - document.body.appendChild(img); - const cnv = document.createElement('canvas'); - cnv.width = 256; cnv.height = 256; - const ctx = cnv.getContext('2d'); - const tex = new THREE.CanvasTexture(cnv); - tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter; tex.generateMipmaps = false; - img.onload = () => { - // size the canvas to the image's aspect once known - cnv.width = img.naturalWidth || 256; - cnv.height = img.naturalHeight || 256; + // Animated WebP (the iOS/WebKit fallback). Three.js can't advance an + // animated WebP in a WebGL texture on iOS, so we render it as a real DOM + // overlay (which animates natively) and project an invisible 3D + // anchor's screen position onto it each frame. The ghost still tracks + // distance/size and bobs, and transparency is native to the WebP. + const ovl = document.createElement('img'); + ovl.crossOrigin = 'anonymous'; ovl.src = image; + ovl.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:5;transform:translate(-50%,-50%);will-change:left,top,width;'; + document.body.appendChild(ovl); + // invisible anchor mesh so the existing bob/lookAt/position math applies + mesh = new THREE.Mesh( + new THREE.PlaneGeometry(1, 1), + new THREE.MeshBasicMaterial({ visible: false }) + ); + group.userData.overlayImg = ovl; + group.userData.overlayAspect = 1; + ovl.onload = () => { + group.userData.overlayAspect = (ovl.naturalWidth || 1) / (ovl.naturalHeight || 1); }; - const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide }); - mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), mat); - group.userData.canvasTex = tex; group.userData.canvasCtx = ctx; group.userData.canvasEl = cnv; - group.userData.animImg = img; } else { const geo = new THREE.SphereGeometry(0.4, 24, 24); const mat = new THREE.MeshStandardMaterial({ @@ -305,7 +299,7 @@ function clearGhost() { if (!current) return; if (current.userData.vidEl) { try { current.userData.vidEl.pause(); current.userData.vidEl.src = ''; } catch {} } - if (current.userData.animImg) { try { current.userData.animImg.remove(); } catch {} } + if (current.userData.overlayImg) { try { current.userData.overlayImg.remove(); } catch {} } scene.remove(current); current = null; } @@ -324,6 +318,7 @@ size = +e.target.value; $('#pv-size-val').textContent = size.toFixed(1); applyTransform(); }); + const _v = new THREE.Vector3(); function loop() { raf = requestAnimationFrame(loop); const t = performance.now() / 1000; @@ -332,14 +327,30 @@ current.lookAt(camera.position); // Desktop WebM-onerror image fallback (rare): refresh its texture. if (current.userData.gifTex && current.userData.gifImg?.complete) current.userData.gifTex.needsUpdate = true; - // Animated WebP path: redraw the current frame into the canvas texture - // so the animation plays (works on iOS, where image textures don't tick). + // iOS WebP overlay: project the invisible anchor to screen space and + // position/scale the DOM so it tracks the 3D ghost (and animates). const ud = current.userData; - if (ud.canvasTex && ud.animImg?.complete && ud.animImg.naturalWidth) { - const c = ud.canvasEl; - ud.canvasCtx.clearRect(0, 0, c.width, c.height); - ud.canvasCtx.drawImage(ud.animImg, 0, 0, c.width, c.height); - ud.canvasTex.needsUpdate = true; + if (ud.overlayImg) { + _v.copy(current.position); + _v.project(camera); + const onScreen = _v.z < 1; + if (onScreen) { + const sx = (_v.x * 0.5 + 0.5) * innerWidth; + const sy = (-_v.y * 0.5 + 0.5) * innerHeight; + // size in px: shrink with distance. fov-based scale at the anchor depth. + const depth = Math.abs(distance); + const worldH = size; // plane is 1 unit * size + const vFov = (camera.fov * Math.PI) / 180; + const viewH = 2 * Math.tan(vFov / 2) * depth; + const px = (worldH / viewH) * innerHeight; + ud.overlayImg.style.display = ''; + ud.overlayImg.style.left = sx + 'px'; + ud.overlayImg.style.top = sy + 'px'; + ud.overlayImg.style.height = px + 'px'; + ud.overlayImg.style.width = px * ud.overlayAspect + 'px'; + } else { + ud.overlayImg.style.display = 'none'; + } } } renderer.render(scene, camera);