Monday, May 6, 2024
HomeWeb developmentHow one can Code an On-Scroll Folding 3D Cardboard Field Animation with...

How one can Code an On-Scroll Folding 3D Cardboard Field Animation with Three.js and GSAP



From our sponsor: Get customized content material suggestions to make your emails extra participating. Join Mailchimp as we speak.

Right now we’ll stroll via the creation of a 3D packaging field that folds and unfolds on scroll. We’ll be utilizing Three.js and GSAP for this.

We received’t use any textures or shaders to set it up. As an alternative, we’ll uncover some methods to govern the Three.js BufferGeometry.

That is what we shall be creating:

Scroll-driven animation

We’ll be utilizing GSAP ScrollTrigger, a useful plugin for scroll-driven animations. It’s an ideal software with an excellent documentation and an energetic group so I’ll solely contact the fundamentals right here.

Let’s arrange a minimal instance. The HTML web page accommodates:

  1. a full-screen <canvas> component with some kinds that can make it cowl the browser window
  2. a <div class=”web page”> component behind the <canvas>. The .web page component a bigger peak than the window so we have now a scrollable component to trace.

On the <canvas> we render a 3D scene with a field component that rotates on scroll.

To rotate the field, we use the GSAP timeline which permits an intuitive approach to describe the transition of the field.rotation.x property.

gsap.timeline({})
    .to(field.rotation, {
        length: 1, // <- takes 1 second to finish
        x: .5 * Math.PI,
        ease: 'power1.out'
    }, 0) // <- begins at zero second (instantly)

The x worth of the field.rotation is altering from 0 (or every other worth that was set earlier than defining the timeline) to 90 levels. The transition begins instantly. It has a length of 1 second and power1.out easing so the rotation slows down on the finish.

As soon as we add the scrollTrigger to the timeline, we begin monitoring the scroll place of the .web page component (see properties set off, begin, finish). Setting the scrub property to true makes the transition not solely begin on scroll however really binds the transition progress to the scroll progress.

gsap.timeline({
    scrollTrigger: {
        set off: '.web page',
        begin: '0% 0%',
        finish: '100% 100%',
        scrub: true,
        markers: true // to debug begin and finish properties
    },
})
    .to(field.rotation, {
        length: 1,
        x: .5 * Math.PI,
        ease: 'power1.out'
    }, 0)

Now field.rotation.x is calculated as a operate of the scroll progress, not as a operate of time. However the easing and timing parameters nonetheless matter. Power1.out easing nonetheless makes the rotation slower on the finish (try ease visualiser software and check out different choices to see the distinction). Begin and length values don’t imply seconds anymore however they nonetheless outline the sequence of the transitions throughout the timeline.

For instance, within the following timeline the final transition is completed at 2.3 + 0.7 = 3.

gsap.timeline({
    scrollTrigger: {
        // ... 
    },
})
    .to(field.rotation, {
        length: 1,
        x: .5 * Math.PI,
        ease: 'power1.out'
    }, 0)
    .to(field.rotation, {
        length: 0.5,
        x: 0,
        ease: 'power2.inOut'
    }, 1)
    .to(field.rotation, {
        length: 0.7, // <- length of the final transition
        x: - Math.PI,
        ease: 'none'
    }, 2.3) // <- begin of the final transition

We take the whole length of the animation as 3. Contemplating that, the primary rotation begins as soon as the scroll begins and takes ⅓ of the web page peak to finish. The second rotation begins with none delay and ends proper in the course of the scroll (1.5 of three). The final rotation begins after a delay and ends after we scroll to the top of the web page. That’s how we are able to assemble the sequences of transitions sure to the scroll.

To get additional with this tutorial, we don’t want greater than some fundamental understanding of GSAP timing and easing. Let me simply point out a number of ideas in regards to the utilization of GSAP ScrollTrigger, particularly for a Three.js scene.

Tip #1: Separating 3D scene and scroll animation

I discovered it helpful to introduce a further variable params = { angle: 0 } to carry animated parameters. As an alternative of instantly altering rotation.x within the timeline, we animate the properties of the “proxy” object, after which use it for the 3D scene (see the updateSceneOnScroll() operate below tip #2). This manner, we preserve scroll-related stuff separate from 3D code. Plus, it makes it simpler to make use of the identical animated parameter for a number of 3D transforms; extra about {that a} bit additional on.

Tip #2: Render scene solely when wanted

Perhaps the commonest approach to render a Three.js scene is asking the render operate throughout the window.requestAnimationFrame() loop. It’s good to keep in mind that we don’t want it, if the scene is static apart from the GSAP animation. As an alternative, the road renderer.render(scene, digital camera) may be merely added to to the onUpdate callback so the scene is redrawing solely when wanted, in the course of the transition.

// No must render the scene on a regular basis
// operate animate() {
//     requestAnimationFrame(animate);
//     // replace objects(s) transforms right here
//     renderer.render(scene, digital camera);
// }

let params = { angle: 0 }; // <- "proxy" object

// Three.js capabilities
operate updateSceneOnScroll() {
    field.rotation.x = angle.v;
    renderer.render(scene, digital camera);
}

// GSAP capabilities
operate createScrollAnimation() {
    gsap.timeline({
        scrollTrigger: {
            // ... 
            onUpdate: updateSceneOnScroll
        },
    })
        .to(angle, {
            length: 1,
            v: .5 * Math.PI,
            ease: 'power1.out'
        })
}

Tip #3: Three.js strategies to make use of with onUpdate callback

Varied properties of Three.js objects (.quaternion, .place, .scale, and so forth) may be animated with GSAP in the identical approach as we did for rotation. However not all of the Three.js strategies would work. 

A few of them are aimed to assign the worth to the property (.setRotationFromAxisAngle(), .setRotationFromQuaternion(), .applyMatrix4(), and so forth.) which works completely for GSAP timelines.

However different strategies add the worth to the property. For instance, .rotateX(.1) would enhance the rotation by 0.1 radians each time it’s referred to as. So in case field.rotateX(angle.v) is positioned to the onUpdate callback, the angle worth shall be added to the field rotation each body and the 3D field will get a bit loopy on scroll. Identical with .rotateOnAxis, .translateX, .translateY and different comparable strategies – they work for animations within the window.requestAnimationFrame() loop however not as a lot for as we speak’s GSAP setup.

View the minimal scroll sandbox right here.

Word: This Three.js scene and different demos under include some extra components like axes strains and titles. They don’t have any impact on the scroll animation and may be excluded from the code simply. Be happy to take away the addAxesAndOrbitControls() operate, the whole lot associated to axisTitles and orbits, and <div> classed ui-controls to get a very minimal setup.

Now that we all know the way to rotate the 3D object on scroll, let’s see the way to create the bundle field.

Field construction

The field consists of 4 x 3 = 12 meshes:

We need to management the place and rotation of these meshes to outline the next:

  • unfolded state
  • folded state 
  • closed state

For starters, let’s say our field doesn’t have flaps so all we have now is 2 width-sides and two length-sides. The Three.js scene with 4 planes would appear to be this:

let field = {
    params: {
        width: 27,
        size: 80,
        depth: 45
    },
    els: {
        group: new THREE.Group(),
        backHalf: {
            width: new THREE.Mesh(),
            size: new THREE.Mesh(),
        },
        frontHalf: {
            width: new THREE.Mesh(),
            size: new THREE.Mesh(),
        }
    }
};

scene.add(field.els.group);
setGeometryHierarchy();
createBoxElements();

operate setGeometryHierarchy() {
    // for now, the field is a bunch with 4 baby meshes
    field.els.group.add(field.els.frontHalf.width, field.els.frontHalf.size, field.els.backHalf.width, field.els.backHalf.size);
}

operate createBoxElements() {
    for (let halfIdx = 0; halfIdx < 2; halfIdx++) {
        for (let sideIdx = 0; sideIdx < 2; sideIdx++) {

            const half = halfIdx ? 'frontHalf' : 'backHalf';
            const aspect = sideIdx ? 'width' : 'size';

            const sideWidth = aspect === 'width' ? field.params.width : field.params.size;
            field.els[half][side].geometry = new THREE.PlaneGeometry(
                sideWidth,
                field.params.depth
            );
        }
    }
}

All 4 sides are by default centered within the (0, 0, 0) level and mendacity within the XY-plane:

Folding animation

To outline the unfolded state, it’s ample to:

  • transfer panels alongside X-axis except for middle so that they don’t overlap

Remodeling it to the folded state means

  • rotating width-sides to 90 deg round Y-axis
  • shifting length-sides to the alternative instructions alongside Z-axis 
  • shifting length-sides alongside X-axis to maintain the field centered

Apart of field.params.width, field.params.size and field.params.depth, the one parameter wanted to outline these states is the opening angle. So the field.animated.openingAngle parameter is added to be animated on scroll from 0 to 90 levels.

let field = {
    params: {
        // ...
    },
    els: {
        // ...
    },
    animated: {
        openingAngle: 0
    }
};

operate createFoldingAnimation() {
    gsap.timeline({
        scrollTrigger: {
            set off: '.web page',
            begin: '0% 0%',
            finish: '100% 100%',
            scrub: true,
        },
        onUpdate: updatePanelsTransform
    })
        .to(field.animated, {
            length: 1,
            openingAngle: .5 * Math.PI,
            ease: 'power1.inOut'
        })
}

Utilizing field.animated.openingAngle, the place and rotation of sides may be calculated

operate updatePanelsTransform() {

    // place width-sides apart of length-sides (not animated)
    field.els.frontHalf.width.place.x = .5 * field.params.size;
    field.els.backHalf.width.place.x = -.5 * field.params.size;

    // rotate width-sides from 0 to 90 deg 
    field.els.frontHalf.width.rotation.y = field.animated.openingAngle;
    field.els.backHalf.width.rotation.y = field.animated.openingAngle;

    // transfer length-sides to maintain the closed field centered
    const cos = Math.cos(field.animated.openingAngle); // animates from 1 to 0
    field.els.frontHalf.size.place.x = -.5 * cos * field.params.width;
    field.els.backHalf.size.place.x = .5 * cos * field.params.width;

    // transfer length-sides to outline field inside house
    const sin = Math.sin(field.animated.openingAngle); // animates from 0 to 1
    field.els.frontHalf.size.place.z = .5 * sin * field.params.width;
    field.els.backHalf.size.place.z = -.5 * sin * field.params.width;
}
View the sandbox right here.

Good! Let’s take into consideration the flaps. We wish them to transfer along with the perimeters after which to rotate round their very own edge to shut the field.

To maneuver the flaps along with the perimeters we merely add them as the kids of the aspect meshes. This manner, flaps inherit all of the transforms we apply to the perimeters. A further place.y transition will place them on prime or backside of the aspect panel.

let field = {
    params: {
        // ...
    },
    els: {
        group: new THREE.Group(),
        backHalf: {
            width: {
                prime: new THREE.Mesh(),
                aspect: new THREE.Mesh(),
                backside: new THREE.Mesh(),
            },
            size: {
                prime: new THREE.Mesh(),
                aspect: new THREE.Mesh(),
                backside: new THREE.Mesh(),
            },
        },
        frontHalf: {
            width: {
                prime: new THREE.Mesh(),
                aspect: new THREE.Mesh(),
                backside: new THREE.Mesh(),
            },
            size: {
                prime: new THREE.Mesh(),
                aspect: new THREE.Mesh(),
                backside: new THREE.Mesh(),
            },
        }
    },
    animated: {
        openingAngle: .02 * Math.PI
    }
};

scene.add(field.els.group);
setGeometryHierarchy();
createBoxElements();

operate setGeometryHierarchy() {
    // as earlier than
    field.els.group.add(field.els.frontHalf.width.aspect, field.els.frontHalf.size.aspect, field.els.backHalf.width.aspect, field.els.backHalf.size.aspect);

    // add flaps
    field.els.frontHalf.width.aspect.add(field.els.frontHalf.width.prime, field.els.frontHalf.width.backside);
    field.els.frontHalf.size.aspect.add(field.els.frontHalf.size.prime, field.els.frontHalf.size.backside);
    field.els.backHalf.width.aspect.add(field.els.backHalf.width.prime, field.els.backHalf.width.backside);
    field.els.backHalf.size.aspect.add(field.els.backHalf.size.prime, field.els.backHalf.size.backside);
}

operate createBoxElements() {
    for (let halfIdx = 0; halfIdx < 2; halfIdx++) {
        for (let sideIdx = 0; sideIdx < 2; sideIdx++) {

            // ...

            const flapWidth = sideWidth - 2 * field.params.flapGap;
            const flapHeight = .5 * field.params.width - .75 * field.params.flapGap;

            // ...

            const flapPlaneGeometry = new THREE.PlaneGeometry(
                flapWidth,
                flapHeight
            );
            field.els[half][side].prime.geometry = flapPlaneGeometry;
            field.els[half][side].backside.geometry = flapPlaneGeometry;
            field.els[half][side].prime.place.y = .5 * field.params.depth + .5 * flapHeight;
            field.els[half][side].backside.place.y = -.5 * field.params.depth -.5 * flapHeight;
        }
    }
}

The flaps rotation is a little more difficult.

Altering the pivot level of Three.js mesh

Let’s get again to the primary instance with a Three.js object rotating across the X axis.

There’re some ways to set the rotation of a 3D object: Euler angle, quaternion, lookAt() operate, remodel matrices and so forth. Whatever the approach angle and axis of rotation are set, the pivot level (remodel origin) shall be on the middle of the mesh.

Say we animate rotation.x for the 4 bins which can be positioned across the scene:

const boxGeometry = new THREE.BoxGeometry(boxSize[0], boxSize[1], boxSize[2]);
const boxMesh = new THREE.Mesh(boxGeometry, boxMaterial);

const numberOfBoxes = 4;
for (let i = 0; i < numberOfBoxes; i++) {
    bins[i] = boxMesh.clone();
    bins[i].place.x = (i - .5 * numberOfBoxes) * (boxSize[0] + 2);
    scene.add(bins[i]);
}
bins[1].place.y = .5 * boxSize[1];
bins[2].rotation.y = .5 * Math.PI;
bins[3].place.y = - boxSize[1];
See the sandbox right here.

For them to rotate across the backside edge, we have to transfer the pivot level to -.5 x field measurement. There are couple of how to do that:

  • wrap mesh with extra Object3D
  • remodel geometry of mesh
  • assign pivot level with extra remodel matrix
  • could possibly be another tips

In the event you’re curious why Three.js doesn’t present origin positioning as a local methodology, try this dialogue.

Choice #1: Wrapping mesh with extra Object3D

For the primary choice, we add the unique field mesh as a toddler of latest Object3D. We deal with the mum or dad object as a field so we apply transforms (rotation.x) to it, precisely as earlier than. However we additionally translate the mesh to half of its measurement. The mesh strikes up within the native house however the origin of the mum or dad object stays in the identical level.

const boxGeometry = new THREE.BoxGeometry(boxSize[0], boxSize[1], boxSize[2]);
const boxMesh = new THREE.Mesh(boxGeometry, boxMaterial);

const numberOfBoxes = 4;
for (let i = 0; i < numberOfBoxes; i++) {
    bins[i] = new THREE.Object3D();
    const mesh = boxMesh.clone();
    mesh.place.y = .5 * boxSize[1];
    bins[i].add(mesh);

    bins[i].place.x = (i - .5 * numberOfBoxes) * (boxSize[0] + 2);
    scene.add(bins[i]);
}
bins[1].place.y = .5 * boxSize[1];
bins[2].rotation.y = .5 * Math.PI;
bins[3].place.y = - boxSize[1];
See the sandbox right here.

Choice #2: Translating the geometry of Mesh

With the second choice, we transfer up the geometry of the mesh. In Three.js, we are able to apply a remodel not solely to the objects but in addition to their geometry.

const boxGeometry = new THREE.BoxGeometry(boxSize[0], boxSize[1], boxSize[2]);
boxGeometry.translate(0, .5 * boxSize[1], 0);
const boxMesh = new THREE.Mesh(boxGeometry, boxMaterial);

const numberOfBoxes = 4;
for (let i = 0; i < numberOfBoxes; i++) {
    bins[i] = boxMesh.clone();
    bins[i].place.x = (i - .5 * numberOfBoxes) * (boxSize[0] + 2);
    scene.add(bins[i]);
}
bins[1].place.y = .5 * boxSize[1];
bins[2].rotation.y = .5 * Math.PI;
bins[3].place.y = - boxSize[1];
See the sandbox right here.

The thought and outcome are the identical: we transfer the mesh up ½ of its peak however the origin level is staying on the identical coordinates. That’s why rotation.x remodel makes the field rotate round its backside aspect.

Choice #3: Assign pivot level with extra remodel matrix

I discover this manner much less appropriate for as we speak’s challenge however the thought behind it’s fairly easy. We take each, pivot level place and desired remodel as matrixes. As an alternative of merely making use of the specified remodel to the field, we apply the inverted pivot level place first, then do rotation.x because the field is centered in the intervening time, after which apply the purpose place.

object.matrix = inverse(pivot.matrix) * someTranformationMatrix * pivot.matrix

Yow will discover a pleasant implementation of this methodology right here.

I’m utilizing geometry translation (choice #2) to maneuver the origin of the flaps. Earlier than getting again to the field, let’s see what we are able to obtain if the exact same rotating bins are added to the scene in hierarchical order and positioned one on prime of one other.

const boxGeometry = new THREE.BoxGeometry(boxSize[0], boxSize[1], boxSize[2]);
boxGeometry.translate(0, .5 * boxSize[1], 0);
const boxMesh = new THREE.Mesh(boxGeometry, boxMaterial);

const numberOfBoxes = 4;
for (let i = 0; i < numberOfBoxes; i++) {
    bins[i] = boxMesh.clone();
    if (i === 0) {
        scene.add(bins[i]);
    } else {
        bins[i - 1].add(bins[i]);
        bins[i].place.y = boxSize[1];
    }
}

We nonetheless animate rotation.x of every field from 0 to 90 levels, so the primary mesh rotates to 90 levels, the second does the identical 90 levels plus its personal 90 levels rotation, the third does 90+90+90 levels, and so forth.

See the sandbox right here.

A very simple and fairly helpful trick.

Animating the flaps

Again to the flaps. Flaps are constructed from translated geometry and added to the scene as youngsters of the aspect meshes. We set their place.y property as soon as and animate their rotation.x property on scroll.

operate setGeometryHierarchy() {
    field.els.group.add(field.els.frontHalf.width.aspect, field.els.frontHalf.size.aspect, field.els.backHalf.width.aspect, field.els.backHalf.size.aspect);
    field.els.frontHalf.width.aspect.add(field.els.frontHalf.width.prime, field.els.frontHalf.width.backside);
    field.els.frontHalf.size.aspect.add(field.els.frontHalf.size.prime, field.els.frontHalf.size.backside);
    field.els.backHalf.width.aspect.add(field.els.backHalf.width.prime, field.els.backHalf.width.backside);
    field.els.backHalf.size.aspect.add(field.els.backHalf.size.prime, field.els.backHalf.size.backside);
}

operate createBoxElements() {
    for (let halfIdx = 0; halfIdx < 2; halfIdx++) {
        for (let sideIdx = 0; sideIdx < 2; sideIdx++) {

            // ...

            const topGeometry = flapPlaneGeometry.clone();
            topGeometry.translate(0, .5 * flapHeight, 0);

            const bottomGeometry = flapPlaneGeometry.clone();
            bottomGeometry.translate(0, -.5 * flapHeight, 0);

            field.els[half][side].prime.place.y = .5 * field.params.depth;
            field.els[half][side].backside.place.y = -.5 * field.params.depth;
        }
    }
}

The animation of every flap has a person timing and easing throughout the gsap.timeline so we retailer the flap angles individually.

let field = {
    // ...
    animated: {
        openingAngle: .02 * Math.PI,
        flapAngles: {
            backHalf: {
                width: {
                    prime: 0,
                    backside: 0
                },
                size: {
                    prime: 0,
                    backside: 0
                },
            },
            frontHalf: {
                width: {
                    prime: 0,
                    backside: 0
                },
                size: {
                    prime: 0,
                    backside: 0
                },
            }
        }
    }
}

operate createFoldingAnimation() {
    gsap.timeline({
        scrollTrigger: {
            // ...
        },
        onUpdate: updatePanelsTransform
    })
        .to(field.animated, {
            length: 1,
            openingAngle: .5 * Math.PI,
            ease: 'power1.inOut'
        })
        .to([ box.animated.flapAngles.backHalf.width, box.animated.flapAngles.frontHalf.width ], {
            length: .6,
            backside: .6 * Math.PI,
            ease: 'again.in(3)'
        }, .9)
        .to(field.animated.flapAngles.backHalf.size, {
            length: .7,
            backside: .5 * Math.PI,
            ease: 'again.in(2)'
        }, 1.1)
        .to(field.animated.flapAngles.frontHalf.size, {
            length: .8,
            backside: .49 * Math.PI,
            ease: 'again.in(3)'
        }, 1.4)
        .to([box.animated.flapAngles.backHalf.width, box.animated.flapAngles.frontHalf.width], {
            length: .6,
            prime: .6 * Math.PI,
            ease: 'again.in(3)'
        }, 1.4)
        .to(field.animated.flapAngles.backHalf.size, {
            length: .7,
            prime: .5 * Math.PI,
            ease: 'again.in(3)'
        }, 1.7)
        .to(field.animated.flapAngles.frontHalf.size, {
            length: .9,
            prime: .49 * Math.PI,
            ease: 'again.in(4)'
        }, 1.8)
}

operate updatePanelsTransform() {

    // ... folding / unfolding

    field.els.frontHalf.width.prime.rotation.x = -box.animated.flapAngles.frontHalf.width.prime;
    field.els.frontHalf.size.prime.rotation.x = -box.animated.flapAngles.frontHalf.size.prime;
    field.els.frontHalf.width.backside.rotation.x = field.animated.flapAngles.frontHalf.width.backside;
    field.els.frontHalf.size.backside.rotation.x = field.animated.flapAngles.frontHalf.size.backside;

    field.els.backHalf.width.prime.rotation.x = field.animated.flapAngles.backHalf.width.prime;
    field.els.backHalf.size.prime.rotation.x = field.animated.flapAngles.backHalf.size.prime;
    field.els.backHalf.width.backside.rotation.x = -box.animated.flapAngles.backHalf.width.backside;
    field.els.backHalf.size.backside.rotation.x = -box.animated.flapAngles.backHalf.size.backside;
}
See the sandbox right here.

With all this, we end the animation half! Let’s now work on the look of our field.

Lights and colours 

This half is so simple as changing multi-color wireframes with a single shade MeshStandardMaterial and including a number of lights.

const ambientLight = new THREE.AmbientLight(0xffffff, .5);
scene.add(ambientLight);
lightHolder = new THREE.Group();
const topLight = new THREE.PointLight(0xffffff, .5);
topLight.place.set(-30, 300, 0);
lightHolder.add(topLight);
const sideLight = new THREE.PointLight(0xffffff, .7);
sideLight.place.set(50, 0, 150);
lightHolder.add(sideLight);
scene.add(lightHolder);

const materials = new THREE.MeshStandardMaterial({
    shade: new THREE.Colour(0x9C8D7B),
    aspect: THREE.DoubleSide
});
field.els.group.traverse(c => {
    if (c.isMesh) c.materials = materials;
});

Tip: Object rotation impact with OrbitControls

OrbitControls make the digital camera orbit across the central level (left preview). To reveal a 3D object, it’s higher to present customers a sense that they rotate the item itself, not the digital camera round it (proper preview). To take action, we preserve the lights place static relative to digital camera.

It may be finished by wrapping lights in a further lightHolder object. The pivot level of the mum or dad object is (0, 0, 0). We additionally know that the digital camera rotates round (0, 0, 0). It means we are able to merely apply the digital camera’s rotation to the lightHolder to maintain the lights static relative to the digital camera.

operate render() {
    // ...
    lightHolder.quaternion.copy(digital camera.quaternion);
    renderer.render(scene, digital camera);
}
See the sandbox right here.

Layered panels

To date, our sides and flaps had been finished as a easy PlaneGeomery. Let’s change it with “actual” corrugated cardboard materials ‐ two covers and a fluted layer between them.


First step is changing a single airplane with 3 planes merged into one. To take action, we have to place 3 clones of PlaneGeometry one behind one other and translate the back and front ranges alongside the Z axis by half of the whole cardboard thickness.

There’re some ways to maneuver the layers, ranging from the geometry.translate(0, 0, .5 * thickness) methodology we used to alter the pivot level. However contemplating different transforms we’re about to use to the cardboard geometry, we higher undergo the geometry.attributes.place array and add the offset to the z-coordinates instantly:

fconst baseGeometry = new THREE.PlaneGeometry(
    params.width,
    params.peak,
);

const geometriesToMerge = [
    getLayerGeometry(- .5 * params.thickness),
    getLayerGeometry(0),
    getLayerGeometry(.5 * params.thickness)
];

operate getLayerGeometry(offset) {
    const layerGeometry = baseGeometry.clone();
    const positionAttr = layerGeometry.attributes.place;
    for (let i = 0; i < positionAttr.rely; i++) {
        const x = positionAttr.getX(i);
        const y = positionAttr.getY(i)
        const z = positionAttr.getZ(i) + offset;
        positionAttr.setXYZ(i, x, y, z);
    }
    return layerGeometry;
}

For merging the geometries we use the mergeBufferGeometries methodology. It’s fairly simple, simply don’t overlook to import the BufferGeometryUtils module into your challenge.

See the sandbox right here.

Wavy flute

To show a mid layer into the flute, we apply the sine wave to the airplane. In reality, it’s the identical z-coordinate offset, simply calculated as Sine operate of the x-attribute as a substitute of a relentless worth.

operate getLayerGeometry() {
    const baseGeometry = new THREE.PlaneGeometry(
        params.width,
        params.peak,
        params.widthSegments,
        1
    );

    const offset = (v) => .5 * params.thickness * Math.sin(params.fluteFreq * v);
    const layerGeometry = baseGeometry.clone();
    const positionAttr = layerGeometry.attributes.place;
    for (let i = 0; i < positionAttr.rely; i++) {
        const x = positionAttr.getX(i);
        const y = positionAttr.getY(i)
        const z = positionAttr.getZ(i) + offset(x);
        positionAttr.setXYZ(i, x, y, z);
    }
    layerGeometry.computeVertexNormals();

    return layerGeometry;
}

The z-offset will not be the one change we want right here. By default, PlaneGeometry is constructed from two triangles. Because it has just one width phase and one peak phase, there’re solely nook vertices. To use the sine(x) wave, we want sufficient vertices alongside the x axis – sufficient decision, you possibly can say.

Additionally, don’t overlook to replace the normals after altering the geometry. It doesn’t occur robotically.

See the sandbox right here.

I apply the wave with an amplitude equal to the cardboard thickness to the center layer, and the identical wave with a bit of amplitude to the back and front layers, simply to present some texture to the field.

The surfaces and cuts look fairly cool. However we don’t need to see the wavy layer on the folding strains. On the identical time, I would like these strains to be seen earlier than the folding occurs:

To attain this, we are able to “press” the cardboard on the chosen edges of every panel.

We are able to achieve this by making use of one other modifier to the z-coordinate. This time it’s an influence operate of the x or y attribute (relying on the aspect we’re “urgent”). 

operate getLayerGeometry() {
    const baseGeometry = new THREE.PlaneGeometry(
        params.width,
        params.peak,
        params.widthSegments,
        params.heightSegments // to use folding we want ample variety of segments on all sides
    );

    const offset = (v) => .5 * params.thickness * Math.sin(params.fluteFreq * v);
    const layerGeometry = baseGeometry.clone();
    const positionAttr = layerGeometry.attributes.place;
    for (let i = 0; i < positionAttr.rely; i++) {
        const x = positionAttr.getX(i);
        const y = positionAttr.getY(i)
        let z = positionAttr.getZ(i) + offset(x); // add wave
        z = applyFolds(x, y, z); // add folds
        positionAttr.setXYZ(i, x, y, z);
    }
    layerGeometry.computeVertexNormals();

    return layerGeometry;
}

operate applyFolds(x, y, z) {
    const folds = [ params.topFold, params.rightFold, params.bottomFold, params.leftFold ];
    const measurement = [ params.width, params.height ];
    let modifier = (c, measurement) => (1. - Math.pow(c / (.5 * measurement), params.foldingPow));

    // prime edge: Z -> 0 when y -> airplane peak,
    // backside edge: Z -> 0 when y -> 0,
    // proper edge: Z -> 0 when x -> airplane width,
    // left edge: Z -> 0 when x -> 0

    if ((x > 0 && folds[1]) || (x < 0 && folds[3])) {
        z *= modifier(x, measurement[0]);
    }
    if ((y > 0 && folds[0]) || (y < 0 && folds[2])) {
        z *= modifier(y, measurement[1]);
    }
    return z;
}
See the sandbox right here.

The folding modifier is utilized to all 4 edges of the field sides, to the underside edges of the highest flaps, and to the highest edges of backside flaps.

With this the field itself is completed.

There’s room for optimization, and for some further options, in fact. For instance, we are able to simply take away the flute degree from the aspect panels because it’s by no means seen anyway. Let me additionally shortly describe the way to add zooming buttons and a aspect picture to our beautiful field.

Zooming

The default behaviour of OrbitControls is zooming the scene by scroll. It signifies that our scroll-driven animation is in battle with it, so we set orbit.enableZoom property to false.

We nonetheless can have zooming on the scene by altering the digital camera.zoom property. We are able to use the identical GSAP animation as earlier than, simply word that animating the digital camera’s property doesn’t robotically replace the digital camera’s projection. In keeping with the documentation, updateProjectionMatrix() have to be referred to as after any change of the digital camera parameters so we have now to name it on each body of the transition:

// ...
// altering the zoomLevel variable with buttons

gsap.to(digital camera, {
    length: .2,
    zoom: zoomLevel,
    onUpdate: () => {
        digital camera.updateProjectionMatrix();
    }
})

Aspect picture

The picture, or perhaps a clickable hyperlink, may be added on the field aspect. It may be finished with a further airplane mesh with a texture on it. It ought to be simply shifting along with the chosen aspect of the field:

operate updatePanelsTransform() {

   // ...

   // for copyright mesh to be positioned on the entrance size aspect of the field
   copyright.place.copy(field.els.frontHalf.size.aspect.place);
   copyright.place.x += .5 * field.params.size - .5 * field.params.copyrightSize[0];
   copyright.place.y -= .5 * (field.params.depth - field.params.copyrightSize[1]);
   copyright.place.z += field.params.thickness;
}

As for the feel, we are able to import a picture/video file, or use a canvas component we create programmatically. Within the remaining demo I take advantage of a canvas with a clear background, and two strains of textual content with an underline. Turning the canvas right into a Three.js texture makes me in a position to map it on the airplane:

operate createCopyright() {
    
    // create canvas
    
    const canvas = doc.createElement('canvas');
    canvas.width = field.params.copyrightSize[0] * 10;
    canvas.peak = field.params.copyrightSize[1] * 10;
    const planeGeometry = new THREE.PlaneGeometry(field.params.copyrightSize[0], field.params.copyrightSize[1]);

    const ctx = canvas.getContext('2nd');
    ctx.clearRect(0, 0, canvas.width, canvas.width);
    ctx.fillStyle = '#000000';
    ctx.font = '22px sans-serif';
    ctx.textAlign = 'finish';
    ctx.fillText('ksenia-k.com', canvas.width - 30, 30);
    ctx.fillText('codepen.io/ksenia-k', canvas.width - 30, 70);

    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(canvas.width - 160, 35);
    ctx.lineTo(canvas.width - 30, 35);
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(canvas.width - 228, 77);
    ctx.lineTo(canvas.width - 30, 77);
    ctx.stroke();

    // create texture

    const texture = new THREE.CanvasTexture(canvas);

    // create mesh mapped with texture

    copyright = new THREE.Mesh(planeGeometry, new THREE.MeshBasicMaterial({
        map: texture,
        clear: true,
        opacity: .5
    }));
    scene.add(copyright);
}

To make the textual content strains clickable, we do the next:

  • use Raycaster and mousemove occasion to trace if the intersection between cursor ray and airplane, change the cursor look if the mesh is hovered
  • if a click on occurred whereas the mesh is hovered, verify the uv coordinate of intersection
  • if the uv coordinate is on the highest half of the mesh (uv.y > .5) we open the primary hyperlink, if uv coordinate is under .5, we go to the second hyperlink

The raycaster code is on the market within the full demo.

Thanks for scrolling this far!
Hope this tutorial may be helpful to your Three.js initiatives ♡

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments