Animate WebP fallback on iOS via per-frame CanvasTexture

Three.js doesn't advance an animated-image Texture on iOS, so the WebP
showed as a static frame. Draw the animating <img> into an offscreen
canvas each frame and sample it through a CanvasTexture, keeping the ghost
in 3D (bob/scale/lookAt) and animated, with transparency preserved.
This commit is contained in:
2026-06-22 09:48:17 +10:00
parent 793a17dbdf
commit 4cf8ec07a4
+27 -3
View File
@@ -246,13 +246,27 @@
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 <img> 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;
const tex = new THREE.Texture(img);
img.onload = () => { tex.needsUpdate = true; };
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;
};
const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide });
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), mat);
group.userData.gifTex = tex; group.userData.gifImg = img;
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({
@@ -311,7 +325,17 @@
if (current) {
current.position.y = Math.sin(t * 1.5 + current.userData.bobPhase) * 0.06;
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).
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;
}
}
renderer.render(scene, camera);
}