9am / 9am.github.io

9am Blog 🕘 ㍡
https://9am.github.io/
MIT License
3 stars 0 forks source link

< img-tissue > #5

Open 9am opened 2 years ago

9am commented 2 years ago
How to make a minimization effect with canvas.
triangle hits
9am commented 2 years ago

If you are using macOS, you'll definitely notice the minimization effect when clicking the little yellow button on the top-left corner. I remember the first time I saw it. The animation is so smoothy, and the action perfectly makes sense.

This time, I'll try to implement this effect using canvas. Actually, after I figure out how to do it. I made a web component out of SVG to enable the animation for any position on the page.

https://user-images.githubusercontent.com/1435457/169258793-a07ca0be-8522-443a-bf99-3fdd8cdb250d.mov

https://user-images.githubusercontent.com/1435457/169258840-bd191233-8136-4331-a96b-888a21a5209f.mov

The Theory

Before writing the codes, let's talk about how the animation effect works.

If we took a screenshot in the middle of the animation, it's like the original image is twisted. This is called Projective Mapping[^1]. It 'projects' a group of points from the current axis system to another with a certain transform method. One common use case is the 3D model texture rendering. The 3D models are just vector data to compose a body shape which have several surfaces. To make it looks real, a texture image is applied to the surfaces like a skin using this method.

uvmapping

Back to our job, to map every pixel in the original image to the target area. Since it's a 2D transform, the documents^2 told us we need a matrix like this. {X, Y} is the points of the original surface and {U, V} is the target. It takes at least 3 pairs of points to solve the equation.

matrix mac-middle

And you may notice that just the rect couldn't make those curvey borders. So we need to use little rects or even better, triangles to trick the eyes, like the 3D model uses triangles to simulate a round surface. Here is a live demo to show the transformation process.

step0

Edit transform

Canvas

  1. Split the area into triangles.
    The split() method takes width, height, column, row to slice big rect into small pieces of triangles. We render the area with triangles and keep the references of points to do the animation.

    // util.js
    export const split = ({ w, h, c, r }) => {
        const wc = w / c;
        const hr = h / r;
        const points = [];
        for (let j = 0; j < r + 1; j++) {
            for (let i = 0; i < c + 1; i++) {
                points.push([i * wc, j * hr]);
            }
        }
        const triangles = points.flatMap((point, i) => {
            if (!((i + 1) % (c + 1)) || i / (c + 1) >= r) {
                return [];
            }
            return [
                [point, points[i + 1], points[i + c + 2]],
                [point, points[i + c + 1], points[i + c + 2]]
            ];
        });
        return { points, triangles };
    };
    // main.js
    const { triangles: dstTriangles } = split({ w: W, h: H, c: 4, r: 4 });
    const render = () => {
        ctx.clearRect(0, 0, W, H);
        dstTriangles.forEach((tri) => {
            ctx.beginPath();
            for (let [i, [x, y]] of tri.entries()) {
                const fn = i ? ctx.lineTo : ctx.moveTo;
                fn.call(ctx, x, y);
            }
            ctx.closePath();
            ctx.stroke();
        });
    };
    step1

    Edit step1

  2. Animate the triangle points to the target when clicking.
    For this step, we listen to the 'click' event and tween points to the target position. To make the effect right, an extra delay is needed for the tween function. More distance, more delay. We use a linear tween function for this part, you can try to replace it to get other vibes.

    // main.js
    const frame = (timestamp) => {
        if (start === undefined) {
            start = timestamp;
            distList = srcPoints.map(([cx, cy]) => Math.hypot(cx - tx, cy - ty));
            distMax = Math.max.apply(null, distList);
        }
        const len = dstPoints.length;
        for (let i = 0; i < len; i++) {
            const dst = dstPoints[i];
            const src = srcPoints[i];
            const delay = (distList[i] / distMax) * duration;
            dst[0] = linear(timestamp - start, src[0], tx, duration, delay);
            dst[1] = linear(timestamp - start, src[1], ty, duration, delay);
        }
        render();
        if (timestamp - start < duration * 2) {
            rid = window.requestAnimationFrame(frame);
        }
    };
    
    canvas.addEventListener("click", (evt) => {
        tx = +evt.offsetX;
        ty = +evt.offsetY;
        if (rid) {
            start = undefined;
            window.cancelAnimationFrame(rid);
        }
        rid = window.requestAnimationFrame(frame);
    });
    // util.js
    export const linear = (t, b, c, d, delay = 0) => {
        const next = b + (c - b) * (Math.max(0, t - delay) / d);
        return c - b > 0 ? Math.min(next, c) : Math.max(next, c);
    };
    step2

    Edit step2

  3. Transform and Clip the image for each triangle.
    Now it's time to rewrite the render() method to texture the triangles using the transform matrix, and clipping the image with the triangle area.

    // main.js
    const render = () => {
        ctx.clearRect(0, 0, W, H);
        dstTriangles.forEach((dst, i) => {
            ctx.save();
            ctx.beginPath();
            for (let [i, [x, y]] of dst.entries()) {
                const fn = i ? ctx.lineTo : ctx.moveTo;
                fn.call(ctx, x, y);
            }
            ctx.clip();
            const src = srcTriangles[i];
            const mat = getTextureMatrix(src, dst);
            ctx.transform.apply(ctx, mat);
            ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height);
            ctx.restore();
        });
    };

    Edit step3

https://user-images.githubusercontent.com/1435457/169260058-d9357976-54b7-4ffc-b92d-f043708e7461.mov

Notice: There are some little white gaps between the triangle areas, those are caused by anti-aliasing, a little scaling-up for the clipping area will fix the issue.

SVG

Why an SVG version you may ask. Yeah, the canvas works perfectly but the effect is restrained by the canvas area since it has a width and height. What if we need to minimize the image to a position outside the area. That's the situation where SVG stepped in.

The <clipPath> can to do the job context.clip does, and the element support transform attribute as well. All we need is there, meet <img-tissue>.

<img-tissue
    src="/9am.jpeg"
    column="8"
    row="8"
    title="9am"
></img-tissue>
const tissue = document.querySelector('img-tissue');
tissue.zoomIn({ clientX: 100, clientY: 100, duration: 300 })
tissue.zoomOut({ clientX: 100, clientY: 100, duration: 300 })

npm npm install @9am/img-tissue
skypack https://cdn.skypack.dev/@9am/img-tissue
unpkg https://unpkg.com/@9am/img-tissue
github

I think it fits the use case of many things, add-to-cart action, image slide show...

Hope you enjoy it, I'll see you next time.



@9am 🕘

References

[^1]: Projective Mappings for Image Warping