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);