Saturday, March 18, 2023
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 partaking. Join Mailchimp at this time.

Right this moment 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 a substitute, we’ll uncover some methods to govern the Three.js BufferGeometry.

That is what we will likely be creating:

Scroll-driven animation

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

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

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

On the <canvas> we render a 3D scene with a field aspect 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, {
        period: 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 period 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 aspect (see properties set off, begin, finish). Setting the scrub property to true makes the transition not solely begin on scroll however truly 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, {
        period: 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 (take a look at ease visualiser software and check out different choices to see the distinction). Begin and period 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, {
        period: 1,
        x: .5 * Math.PI,
        ease: 'power1.out'
    }, 0)
    .to(field.rotation, {
        period: 0.5,
        x: 0,
        ease: 'power2.inOut'
    }, 1)
    .to(field.rotation, {
        period: 0.7, // <- period of the final transition
        x: - Math.PI,
        ease: 'none'
    }, 2.3) // <- begin of the final transition

We take the overall period 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 midst of the scroll (1.5 of three). The final rotation begins after a delay and ends after we scroll to the tip of the web page. That’s how we will 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 suggestions concerning 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 an extra variable params = { angle: 0 } to carry animated parameters. As a substitute of immediately 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 underneath tip #2). This fashion, 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

Possibly 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 a substitute, the road renderer.render(scene, digital camera) will be merely added to to the onUpdate callback so the scene is redrawing solely when wanted, through the transition.

// No have to 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, {
            period: 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 many others) will be animated with GSAP in the identical manner 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 many others.) which works completely for GSAP timelines.

However different strategies add the worth to the property. For instance, .rotateX(.1) would improve 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 will likely be added to the field rotation each body and the 3D field will get a bit loopy on scroll. Similar with .rotateOnAxis, .translateX, .translateY and different comparable strategies – they work for animations within the window.requestAnimationFrame() loop however not as a lot for at this time’s GSAP setup.

View the minimal scroll sandbox right here.

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

Now that we all know tips on how to rotate the 3D object on scroll, let’s see tips on how to create the package deal 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’ve got 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 youngster 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 adequate to:

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

Remodeling it to the folded state means

  • rotating width-sides to 90 deg round Y-axis
  • transferring length-sides to the alternative instructions alongside Z-axis 
  • transferring 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, {
            period: 1,
            openingAngle: .5 * Math.PI,
            ease: 'power1.inOut'
        })
}

Utilizing field.animated.openingAngle, the place and rotation of sides will 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 would like 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 youngsters of the aspect meshes. This fashion, flaps inherit all of the transforms we apply to the perimeters. An extra 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 tough.

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 manner angle and axis of rotation are set, the pivot level (remodel origin) will likely be on the middle of the mesh.

Say we animate rotation.x for the 4 containers which might 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++) {
    containers[i] = boxMesh.clone();
    containers[i].place.x = (i - .5 * numberOfBoxes) * (boxSize[0] + 2);
    scene.add(containers[i]);
}
containers[1].place.y = .5 * boxSize[1];
containers[2].rotation.y = .5 * Math.PI;
containers[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 dimension. There are couple of the way to do that:

  • wrap mesh with extra Object3D
  • remodel geometry of mesh
  • assign pivot level with extra remodel matrix
  • might be another methods

For those who’re curious why Three.js doesn’t present origin positioning as a local methodology, take a look at this dialogue.

Possibility #1: Wrapping mesh with extra Object3D

For the primary choice, we add the unique field mesh as a baby of recent Object3D. We deal with the father or mother 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 dimension. The mesh strikes up within the native house however the origin of the father or mother 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++) {
    containers[i] = new THREE.Object3D();
    const mesh = boxMesh.clone();
    mesh.place.y = .5 * boxSize[1];
    containers[i].add(mesh);

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

Possibility #2: Translating the geometry of Mesh

With the second choice, we transfer up the geometry of the mesh. In Three.js, we will apply a remodel not solely to the objects but additionally 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++) {
    containers[i] = boxMesh.clone();
    containers[i].place.x = (i - .5 * numberOfBoxes) * (boxSize[0] + 2);
    scene.add(containers[i]);
}
containers[1].place.y = .5 * boxSize[1];
containers[2].rotation.y = .5 * Math.PI;
containers[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.

Possibility #3: Assign pivot level with extra remodel matrix

I discover this fashion much less appropriate for at this time’s mission however the concept behind it’s fairly easy. We take each, pivot level place and desired remodel as matrixes. As a substitute 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 mean 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 will obtain if the exact same rotating containers 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++) {
    containers[i] = boxMesh.clone();
    if (i === 0) {
        scene.add(containers[i]);
    } else {
        containers[i - 1].add(containers[i]);
        containers[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 many others.

See the sandbox right here.

An easy and fairly helpful trick.

Animating the flaps

Again to the flaps. Flaps are created 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, {
            period: 1,
            openingAngle: .5 * Math.PI,
            ease: 'power1.inOut'
        })
        .to([ box.animated.flapAngles.backHalf.width, box.animated.flapAngles.frontHalf.width ], {
            period: .6,
            backside: .6 * Math.PI,
            ease: 'again.in(3)'
        }, .9)
        .to(field.animated.flapAngles.backHalf.size, {
            period: .7,
            backside: .5 * Math.PI,
            ease: 'again.in(2)'
        }, 1.1)
        .to(field.animated.flapAngles.frontHalf.size, {
            period: .8,
            backside: .49 * Math.PI,
            ease: 'again.in(3)'
        }, 1.4)
        .to([box.animated.flapAngles.backHalf.width, box.animated.flapAngles.frontHalf.width], {
            period: .6,
            prime: .6 * Math.PI,
            ease: 'again.in(3)'
        }, 1.4)
        .to(field.animated.flapAngles.backHalf.size, {
            period: .7,
            prime: .5 * Math.PI,
            ease: 'again.in(3)'
        }, 1.7)
        .to(field.animated.flapAngles.frontHalf.size, {
            period: .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 colour 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({
    colour: new THREE.Shade(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 show a 3D object, it’s higher to provide customers a sense that they rotate the thing 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 an extra lightHolder object. The pivot level of the father or mother object is (0, 0, 0). We additionally know that the digital camera rotates round (0, 0, 0). It means we will 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 exchange it with “actual” corrugated cardboard materials ‐ two covers and a fluted layer between them.


First step is changing a single aircraft 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 overall cardboard thickness.

There’re some ways to maneuver the layers, ranging from the geometry.translate(0, 0, .5 * thickness) methodology we used to vary 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 immediately:

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.depend; 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 neglect to import the BufferGeometryUtils module into your mission.

See the sandbox right here.

Wavy flute

To show a mid layer into the flute, we apply the sine wave to the aircraft. In truth, it’s the identical z-coordinate offset, simply calculated as Sine operate of the x-attribute as a substitute of a continuing 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.depend; 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 is just not 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 neglect to replace the normals after altering the geometry. It doesn’t occur mechanically.

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 amplitude to the back and front layers, simply to provide 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 traces. On the identical time, I need these traces to be seen earlier than the folding occurs:

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

We will 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 adequate variety of segments on both 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.depend; 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 dimension = [ params.width, params.height ];
    let modifier = (c, dimension) => (1. - Math.pow(c / (.5 * dimension), params.foldingPow));

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

    if ((x > 0 && folds[1]) || (x < 0 && folds[3])) {
        z *= modifier(x, dimension[0]);
    }
    if ((y > 0 && folds[0]) || (y < 0 && folds[2])) {
        z *= modifier(y, dimension[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, after all. For instance, we will simply take away the flute degree from the aspect panels because it’s by no means seen anyway. Let me additionally rapidly describe tips on how to add zooming buttons and a aspect picture to our attractive 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 will use the identical GSAP animation as earlier than, simply notice that animating the digital camera’s property doesn’t mechanically replace the digital camera’s projection. Based on the documentation, updateProjectionMatrix() have to be referred to as after any change of the digital camera parameters so we’ve got to name it on each body of the transition:

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

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

Aspect picture

The picture, or perhaps a clickable hyperlink, will be added on the field aspect. It may be finished with an extra aircraft mesh with a texture on it. It needs to be simply transferring 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 will import a picture/video file, or use a canvas aspect we create programmatically. Within the ultimate demo I exploit a canvas with a clear background, and two traces of textual content with an underline. Turning the canvas right into a Three.js texture makes me capable of map it on the aircraft:

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('second');
    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 traces clickable, we do the next:

  • use Raycaster and mousemove occasion to trace if the intersection between cursor ray and aircraft, 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 beneath .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 will be helpful on your Three.js initiatives ♡

Inspirational Web sites Roundup #44

RELATED ARTICLES

Most Popular

Recent Comments