preface

Hello, this is CSS magic – Alphardex.

We see a lot of hover and scroll effects, most of which are implemented using CSS and SVG, but there’s one effect they definitely can’t do: distort. Why? CSS is good at linear transformations, while SVG is good at curvilinear transformations. Contortions are neither. They are pixel-level transformations, and the only thing that can do that is canvas, and WebGL’s pixel shaders are really good at that, and we can use them to do all sorts of cool contortions. Here’s a look at the final implementation

Ah, Hajima route!

The preparatory work

The author’s three.js template can be copied by clicking on fork in the lower right corner

Implementation approach

The most important thing to achieve in this article is to synchronize the HTML world with the WebGL world!

Once the two worlds are in sync, all sorts of cool effects can be applied to native HTML elements without affecting basic interaction!

The world

Set the HTML

Step 1: Create a simple HTML page with all the images you want to display

<main class="overflow-hidden">
  <div data-scroll>
    <div class="relative w-screen h-screen flex-center">
      <img class="w-240 h-120" src="https://i.loli.net/2019/11/16/cqyJiYlRwnTeHmj.jpg" alt="" crossorigin="anonymous" />
    </div>
    <div class="relative w-screen h-screen flex-center">
      <img class="w-240 h-120" src="https://i.loli.net/2019/10/18/Ujf6n75o8TtIsWX.jpg" alt="" crossorigin="anonymous" />
    </div>
    <div class="relative w-screen h-screen flex-center">
      <img class="w-240 h-120" src="https://i.loli.net/2019/10/18/buDT4YS6zUMfHst.jpg" alt="" crossorigin="anonymous" />
    </div>
    <div class="relative w-screen h-screen flex-center">
      <img class="w-240 h-120" src="https://i.loli.net/2019/10/18/uXF1Kx7lzELB6wf.jpg" alt="" crossorigin="anonymous" />
    </div>
    <div class="relative w-screen h-screen flex-center">
      <img class="w-240 h-120" src="https://i.loli.net/2019/11/03/RtVq2wxQYySDb8L.jpg" alt="" crossorigin="anonymous" />
    </div>
    <div class="relative w-screen h-screen flex-center">
      <img class="w-240 h-120" src="https://i.loli.net/2019/11/16/FLnzi5Kq4tkRZSm.jpg" alt="" crossorigin="anonymous" />
    </div>
  </div>
</main>
<div class="twisted-gallery fixed -z-1 inset-0 w-screen h-screen"></div>
Copy the code
.w-240 {
  width: 60rem;
}

.h-120 {
  height: 30rem;
}
Copy the code

When you see the neat arrangement of images, you can hide them

img {
  opacity: 0;
}
Copy the code

In our main class, we create a general function, and comment out any unimplemented functions that we will implement later

class TwistedGallery extends Base {...async init() {
    this.createScene();
    this.createPerspectiveCamera();
    this.createRenderer();
    this.createPlane();
    // await preloadImages();
    // this.createDistortImageMaterial();
    // this.createImageDOMMeshObjs();
    // this.setImagesPosition();
    // this.listenScroll();
    // this.createPostprocessingEffect()
    this.createLight();
    this.createOrbitControls();
    this.addListeners();
    this.setLoop(); }... }const start = () = > {
  const twistedGallery = new TwistedGallery(".twisted-gallery".true);
  twistedGallery.init();
};
Copy the code

Synchronize HTML and WebGL units

In order to render all the IMG in HTML in WebGL, we need to synchronize the pixel information of the IMG in HTML to WebGL

The question is, how do I make 1 unit in WebGL === 1px in HTML?

This is where we move on to the classic perspective diagram, and we need to calculate the foV according to the formula

class TwistedGallery extends Base {
  constructor(sel: string, debug: boolean){...this.cameraPosition = new THREE.Vector3(0.0.600);
    const fov = this.getScreenFov();
    this.perspectiveCameraParams = {
      fov,
      near: 100.far: 2000}; }getScreenFov() {
    return ky.rad2deg(
      2 * Math.atan(window.innerHeight / 2 / this.cameraPosition.z) ); }}Copy the code

To verify this, set the width and height of the PlaneBufferGeometry function in createPlane to 100

The width and height of the image are 100px, exactly the same as the HTML!

The createPlane function has completed its test mission and can be deleted

Make sure the image loads

We want to make sure that all images are loaded before we display them, so we use the imagesLoaded library to check

import imagesLoaded from "https://cdn.skypack.dev/[email protected]";

const preloadImages = (sel = "img") = > {
  return new Promise((resolve) = > {
    imagesLoaded(sel, { background: true }, resolve);
  });
};
Copy the code

Display the image on WebGL

We create a DOMMeshObject class that acts as a bridge to synchronize the information in the DOM to the WebGL world

You first get the length, width and position of the DOM elements, and then use this data to calculate their corresponding position and size in WebGL

class DOMMeshObject {
  constructor(
    el: Element,
    scene: THREE.Scene,
    material: THREE.Material = new THREE.MeshBasicMaterial({ color: 0xff0000 })
  ) {
    this.el = el;
    const rect = el.getBoundingClientRect();
    this.rect = rect;
    const { width, height } = rect;
    const geometry = new THREE.PlaneBufferGeometry(width, height, 10.10);
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
    this.mesh = mesh;
  }
  setPosition() {
    const { mesh, rect } = this;
    const { top, left, width, height } = rect;
    const x = left + width / 2 - window.innerWidth / 2;
    const y = -(top + height / 2 - window.innerHeight / 2) + window.scrollY;
    mesh.position.set(x, y, 0); }}Copy the code

Create the image material in the WebGL world and pass the IMG element in HTML as a map to the shader, then synchronize the location

class TwistedGallery extends Base {
  constructor(sel: string, debug: boolean){...this.images = [...document.querySelectorAll("img")];
    this.imageDOMMeshObjs = [];
  }
  // Create a material
  createDistortImageMaterial() {
    const distortImageMaterial = new THREE.ShaderMaterial({
      vertexShader: twistedGalleryMainVertexShader,
      fragmentShader: twistedGalleryMainFragmentShader,
      side: THREE.DoubleSide,
      uniforms: {
        uTexture: {
          value: 0}}});this.distortImageMaterial = distortImageMaterial;
  }
  // Create the image DOM object
  createImageDOMMeshObjs() {
    const { images, scene, distortImageMaterial } = this;
    const imageDOMMeshObjs = images.map((image) = > {
      const texture = new THREE.Texture(image);
      texture.needsUpdate = true;
      const material = distortImageMaterial.clone();
      material.uniforms.uTexture.value = texture;
      const imageDOMMeshObj = new DOMMeshObject(image, scene, material);
      return imageDOMMeshObj;
    });
    this.imageDOMMeshObjs = imageDOMMeshObjs;
  }
  // Set the image position
  setImagesPosition() {
    const { imageDOMMeshObjs } = this;
    imageDOMMeshObjs.forEach((obj) = >{ obj.setPosition(); }); }}Copy the code

Vertex shader twistedGalleryMainVertexShader concur with the template

varying vec2 vUv;

void main(){
    vec4 modelPosition=modelMatrix*vec4(position,1.);
    vec4 viewPosition=viewMatrix*modelPosition;
    vec4 projectedPosition=projectionMatrix*viewPosition;
    gl_Position=projectedPosition;
    
    vUv=uv;
}
Copy the code

A fragment shader twistedGalleryMainFragmentShader will map as a color display

uniform sampler2D uTexture;

varying vec2 vUv;

void main(){
    vec2 newUv=vUv;
    vec4 texture=texture2D(uTexture,newUv);
    vec3 color=texture.rgb;
    gl_FragColor=vec4(color,1.);
}
Copy the code

After some effort, the image appears on the screen, and now we have to scroll it around

Scroll up to

The scroll listener in this case can be implemented using the native Scroll event, but this article doesn’t do that. Why? Because the native Scroll event can only get the position of the scroll, not the speed of the scroll. If the user rolls fast, we need to show that in our special effects. So we’re going to use a library called Ectomic-Scroll, which captures the position and speed of the user’s scrolling

import LocomotiveScroll from "https://cdn.skypack.dev/[email protected]";

class TwistedGallery extends Base {
  // Listen for scrolling
  listenScroll() {
    const scroll = new LocomotiveScroll({
      getSpeed: true
    });
    scroll.on("scroll".() = > {
      this.setImagesPosition();
    });
    this.scroll = scroll; }}Copy the code

Images are finally scrolling in the WEBGL world, and the two worlds are in sync

Now we begin our main show – distortion special effects!

Distortion effects

The effect at the beginning can be seen as a full-screen distortion of the image, so we’re going to use Postprocessing for that, which provides on-screen tDiffuse

import { RenderPass } from "https://cdn.skypack.dev/[email protected]/examples/jsm/postprocessing/RenderPass.js";
import { ShaderPass } from "https://cdn.skypack.dev/[email protected]/examples/jsm/postprocessing/ShaderPass.js";
import gsap from "https://cdn.skypack.dev/[email protected]";

class TwistedGallery extends Base {
  constructor(sel: string, debug: boolean){...this.scrollSpeed = 0;
  }
  // Create a post-processing effect
  createPostprocessingEffect() {
    const composer = new EffectComposer(this.renderer);
    const renderPass = new RenderPass(this.scene, this.camera);
    composer.addPass(renderPass);
    const customPass = new ShaderPass({
      vertexShader: twistedGalleryPostprocessingVertexShader,
      fragmentShader: twistedGalleryPostprocessingFragmentShader,
      uniforms: {
        tDiffuse: {
          value: null
        },
        uRadius: {
          value: 0.75
        },
        uPower: {
          value: 0}}}); customPass.renderToScreen =true;
    composer.addPass(customPass);
    this.composer = composer;
    this.customPass = customPass;
  }
  // Set the scroll speed
  setScrollSpeed() {
    const scrollSpeed = this.scroll.scroll.instance.speed || 0;
    gsap.to(this, {
      scrollSpeed: Math.min(Math.abs(scrollSpeed) * 1.25.2),
      duration: 1
    });
  }
  / / animation
  update() {
    if (this.customPass) {
      this.setScrollSpeed();
      this.customPass.uniforms.uPower.value = this.scrollSpeed; }}}Copy the code

As you can see, we passed the scroll speed as uniform to the shader, and used GSAP to implement the easing effect. Now comes the shader part

Vertex shader twistedGalleryPostprocessingVertexShader keep up with the template, wen said the skip

Fragment shader twistedGalleryPostprocessingFragmentShader is responsible for the dynamic calculation of uv coordinates, a bit difficult to implement, the key is how to move, the value displayed with gl_FragColor inside, it’ll be easier to understand value changes

uniform sampler2D tDiffuse;
uniform float uRadius;
uniform float uPower;

varying vec2 vUv;

void main(){
    vec2 pivot=vec2(. 5);
    vec2 d=vUv-pivot;
    float rDist=length(d);
    float gr=pow(rDist/uRadius,uPower);
    float mag=2.-cos(gr1.);
    vec2 uvR=pivot+d*mag;
    vec4 color=texture2D(tDiffuse,uvR);
    gl_FragColor=color;
}
Copy the code

The project address

Twisted Gallery

The last

The effects implemented in this article are just one of many, but what really matters is the process of synchronizing HTML with the WebGL world, and once you’ve mastered this process, making a really cool site isn’t that hard.

This article is participating in the “Nuggets 2021 Spring Recruitment Campaign”, click to see the details of the campaign