three.js创建光线_使用Three.js创建类似水的扭曲效果

three.js创建光线_使用Three.js创建类似水的扭曲效果_第1张图片

three.js创建光线

three.js创建光线_使用Three.js创建类似水的扭曲效果_第2张图片

In this tutorial we’re going to build a water-like effect with a bit of basic math, a canvas, and postprocessing. No fluid simulation, GPGPU, or any of that complicated stuff. We’re going to draw pretty circles in a canvas, and distort the scene with the result.

在本教程中,我们将使用一些基本数学,画布和后处理来构建类似于水的效果。 没有流体模拟, GPGPU或任何复杂的东西。 我们将在画布上绘制漂亮的圆圈,并使用结果扭曲场景。

We recommend that you get familiar with the basics of Three.js because we’ll omit some of the setup. But don’t worry, most of the tutorial will deal with good old JavaScript and the canvas API. Feel free to chime in if you don’t feel too confident on the Three.js parts.

我们建议您熟悉Three.js的基础知识,因为我们将省略一些设置。 但是不用担心,本教程的大部分内容都将处理良好的旧JavaScript和canvas API。 如果您对Three.js部分不太自信,请随时鸣叫。

演示地址

The effect is divided into two main parts:

效果分为两个主要部分:

  1. Capturing and drawing the ripples to a canvas

    捕捉并绘制涟漪到画布上
  2. Displacing the rendered scene with postprocessing

    用后期处理替换渲染的场景

Let’s start with updating and drawing the ripples since that’s what constitutes the core of the effect.

让我们从更新和绘制涟漪开始,因为那是构成效果的核心。

产生涟漪 (Making the ripples)

The first idea that comes to mind is to use the current mouse position as a uniform and then simply displace the scene and call it a day. But that would mean only having one ripple that always remains at the mouse’s position. We want something more interesting, so we want many independent ripples moving at different positions. For that we’ll need to keep track of each one of them.

想到的第一个想法是将当前鼠标位置用作制服,然后简单地移动场景并将其命名为“ day”。 但这意味着只有一个波纹始终保持在鼠标的位置。 我们想要更有趣的东西,所以我们想要许多独立的涟漪在不同的位置移动。 为此,我们需要跟踪它们中的每一个。

We’re going to create a WaterTexture class to manage everything related to the ripples:

我们将创建一个WaterTexture类来管理与涟漪有关的所有事情:

  1. Capture every mouse movement as a new ripple in an array.

    将每个鼠标移动捕获为阵列中的新波纹。
  2. Draw the ripples to a canvas

    将涟漪画到画布上
  3. Erase the ripples when their lifespan is over

    消除使用寿命过长的涟漪
  4. Move the ripples using their initial momentum

    利用初始动量移动涟漪

For now, let’s begin coding by creating our main App class.

现在,让我们开始通过创建我们的主App类进行编码。

import { WaterTexture } from './WaterTexture';
class App{
    constructor(){
        this.waterTexture = new WaterTexture({ debug: true });
        
        this.tick = this.tick.bind(this);
    	this.init();
    }
    init(){
        this.tick();
    }
    tick(){
        this.waterTexture.update();
        requestAnimationFrame(this.tick);
    }
}
const myApp = new App();

Let’s create our ripple manager WaterTexture with a teeny-tiny 64px canvas.

让我们用一个很小的64px画布创建涟漪管理器WaterTexture

export class WaterTexture{
  constructor(options) {
    this.size = 64;
      this.radius = this.size * 0.1;
     this.width = this.height = this.size;
    if (options.debug) {
      this.width = window.innerWidth;
      this.height = window.innerHeight;
      this.radius = this.width * 0.05;
    }
      
    this.initTexture();
      if(options.debug) document.body.append(this.canvas);
  }
    // Initialize our canvas
  initTexture() {
    this.canvas = document.createElement("canvas");
    this.canvas.id = "WaterTexture";
    this.canvas.width = this.width;
    this.canvas.height = this.height;
    this.ctx = this.canvas.getContext("2d");
    this.clear();
	
  }
  clear() {
    this.ctx.fillStyle = "black";
    this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
  }
  update(){}
}

Note that for development purposes there is a debug option to mount the canvas to the DOM and give it a bigger size. In the end result we won’t be using this option.

请注意,出于开发目的,有一个调试选项可将画布安装到DOM并为其提供更大的尺寸。 最终结果,我们将不会使用此选项。

Now we can go ahead and start adding some of the logic to make our ripples work:

现在,我们可以继续并开始添加一些逻辑以使波动起作用:

  1. On constructor() add

    constructor()添加

    1. this.points array to keep all our ripples

      this.points数组保持我们所有的涟漪

    2. this.radius for the max-radius of a ripple

      this.radius为波纹的最大半径

    3. this.maxAge for the max-age of a ripple

      this.maxAge表示涟漪的最大值

    On constructor() add

    constructor()添加

  2. On Update(),

    Update()

    1. clear the canvas

      清除画布
    2. sing happy birthday to each ripple, and remove those older than this.maxAge

      为每个涟漪唱生日快乐,并删除早this.maxAge那些this.maxAge

    3. draw each ripple

      画出每一个涟漪

    On Update(),

    Update()

  3. Create AddPoint(), which is going to take a normalized position and add a new point to the array.

    创建AddPoint() ,它将采用标准化位置并将新点添加到数组中。

class WaterTexture(){
    constructor(){
        this.size = 64;
        this.radius = this.size * 0.1;
        
        this.points = [];
        this.maxAge = 64;
        ...
    }
    ...
    addPoint(point){
		this.points.push({ x: point.x, y: point.y, age: 0 });
    }
	update(){
        this.clear();
        this.points.forEach(point => {
            point.age += 1;
            if(point.age > this.maxAge){
                this.points.splice(i, 1);
            }
        })
        this.points.forEach(point => {
            this.drawPoint(point);
        })
    }
}

Note that AddPoint() receives normalized values, from 0 to 1. If the canvas happens to resize, we can use the normalized points to draw using the correct size.

请注意, AddPoint()接收从0到1的归一化值。如果画布恰好要调整大小,我们可以使用归一化点以正确的大小进行绘制。

Let’s create drawPoint(point) to start drawing the ripples: Convert the normalized point coordinates into canvas coordinates. Then, draw a happy little circle:

让我们创建drawPoint(point)以开始绘制波纹:将归一化的点坐标转换为画布坐标。 然后,画一个快乐的小圆圈:

class WaterTexture(){
    ...
    drawPoint(point) {
        // Convert normalized position into canvas coordinates
        let pos = {
            x: point.x * this.width,
            y: point.y * this.height
        }
        const radius = this.radius;
        
        
        this.ctx.beginPath();
        this.ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
        this.ctx.fill();
    }
}

For our ripples to have a strong push at the center and a weak force at the edges, we’ll make our circle a Radial Gradient, which looses transparency as it moves to the edges.

为了使涟漪在中心具有较强的推动力,而在边缘具有较弱的作用力,我们将使圆成为“径向渐变”,以使其在移动到边缘时失去透明度。

Radial Gradients create a dithering-like effect when a lot of them overlap. It looks stylish but not as smooth as what we want it to look like.

当许多渐变重叠时,径向渐变会产生类似抖动的效果。 它看起来很时尚,但没有我们想要的光滑。

To make our ripples smooth, we’ll use the circle’s shadow instead of using the circle itself. Shadows give us the gradient-like result without the dithering-like effect. The difference is in the way shadows are painted to the canvas.

为了使涟漪平滑,我们将使用圆的阴影而不是圆本身。 阴影为我们提供了类似梯度的效果,而没有类似抖动的效果。 区别在于将阴影绘制到画布上的方式。

Since we only want to see the shadow and not the flat-colored circle, we’ll give the shadow a high offset. And we’ll move the circle in the opposite direction.

由于我们只希望看到阴影而不是纯色圆圈,因此我们将为阴影提供高偏移量。 然后,我们将沿相反的方向移动圆。

As the ripple gets older, we’ll reduce it’s opacity until it disappears:

随着涟漪变老,我们将降低其不透明度,直到消失为止:

export class WaterTexture(){
    ...
    drawPoint(point) {
        ... 
        const ctx = this.ctx;
        // Lower the opacity as it gets older
        let intensity = 1.;
        intensity = 1. - point.age / this.maxAge;
        
        let color = "255,255,255";
        
        let offset = this.width * 5.;
        // 1. Give the shadow a high offset.
        ctx.shadowOffsetX = offset; 
        ctx.shadowOffsetY = offset; 
        ctx.shadowBlur = radius * 1; 
        ctx.shadowColor = `rgba(${color},${0.2 * intensity})`; 

        
        this.ctx.beginPath();
        this.ctx.fillStyle = "rgba(255,0,0,1)";
        // 2. Move the circle to the other direction of the offset
        this.ctx.arc(pos.x - offset, pos.y - offset, radius, 0, Math.PI * 2);
        this.ctx.fill();
    }
}

To introduce interactivity, we’ll add the mousemove event listener to app class and send the normalized mouse position to WaterTexture.

为了介绍交互性,我们将mousemove事件侦听器添加到app类,并将标准化的鼠标位置发送到WaterTexture

import { WaterTexture } from './WaterTexture';
class App {
	...
	init(){
        window.addEventListener('mousemove', this.onMouseMove.bind(this));
        this.tick();
	}
	onMouseMove(ev){
        const point = {
			x: ev.clientX/ window.innerWidth, 
			y: ev.clientY/ window.innerHeight, 
        }
        this.waterTexture.addPoint(point);
	}
}

演示地址

Great, now we’ve created a disappearing trail of ripples. Now, let’s give them some momentum!

太好了,现在我们创建了消失的涟漪痕迹。 现在,让我们给他们一些动力!

动量 (Momentum)

To give momentum to a ripple, we need its direction and force. Whenever we create a new ripple, we’ll compare its position with the last ripple. Then we’ll calculate its unit vector and force.

为了给涟漪提供动力,我们需要它的方向和力量。 每当我们创建新的涟漪图时,我们都会将其位置与上一个涟漪图进行比较。 然后,我们将计算其单位矢量和力。

On every update, we’ll update the ripples’ positions with their unit vector and position. And as they get older we’ll move them slower and slower until they retire or go live on a farm. Whatever happens first.

每次更新时,我们都会使用涟漪的位置矢量和位置来更新它们的位置。 随着年龄的增长,我们会越来越慢地移动它们,直到它们退休或住在农场。 不管先发生什么。

export lass WaterTexture{
	...
    constructor(){
        ...
        this.last = null;
    }
    addPoint(point){
        let force = 0;
        let vx = 0;
        let vy = 0;
        const last = this.last;
        if(last){
            const relativeX = point.x - last.x;
            const relativeY = point.y - last.y;
            // Distance formula
            const distanceSquared = relativeX * relativeX + relativeY * relativeY;
            const distance = Math.sqrt(distanceSquared);
            // Calculate Unit Vector
            vx = relativeX / distance;
            vy = relativeY / distance;
            
            force = Math.min(distanceSquared * 10000,1.);
        }
        
        this.last = {
            x: point.x,
            y: point.y
        }
        this.points.push({ x: point.x, y: point.y, age: 0, force, vx, vy });
    }
	
	update(){
        this.clear();
        let agePart = 1. / this.maxAge;
        this.points.forEach((point,i) => {
            let slowAsOlder = (1.- point.age / this.maxAge)
            let force = point.force * agePart * slowAsOlder;
              point.x += point.vx * force;
              point.y += point.vy * force;
            point.age += 1;
            if(point.age > this.maxAge){
                this.points.splice(i, 1);
            }
        })
        this.points.forEach(point => {
            this.drawPoint(point);
        })
    }
}

Note that instead of using the last ripple in the array, we use a dedicated this.last. This way, our ripples always have a point of reference to calculate their force and unit vector.

请注意,我们使用专用的this.last而不是使用数组中的最后一个波纹。 这样,我们的波纹总是有一个参考点来计算其力和单位矢量。

演示地址

Let’s fine-tune the intensity with some easings. Instead of just decreasing until it’s removed, we’ll make it increase at the start and then decrease:

让我们通过一些缓和微调强度。 我们将在开始时将其增加,然后减少:

const easeOutSine = (t, b, c, d) => {
  return c * Math.sin((t / d) * (Math.PI / 2)) + b;
};

const easeOutQuad = (t, b, c, d) => {
  t /= d;
  return -c * t * (t - 2) + b;
};

export class WaterTexture(){
	drawPoint(point){
	...
	let intensity = 1.;
        if (point.age < this.maxAge * 0.3) {
          intensity = easeOutSine(point.age / (this.maxAge * 0.3), 0, 1, 1);
        } else {
          intensity = easeOutQuad(
            1 - (point.age - this.maxAge * 0.3) / (this.maxAge * 0.7),
            0,
            1,
            1
          );
        }
        intensity *= point.force;
        ...
	}
}

演示地址

Now we're finished with creating and updating the ripples. It's looking amazing.

现在,我们完成了创建和更新波纹的操作。 看起来很棒。

But how do we use what we have painted to the canvas to distort our final scene?

但是,我们如何使用在画布上绘制的图像扭曲最终场景呢?

画布作为纹理 (Canvas as a texture)

Let's use the canvas as a texture, hence the name WaterTexture. We are going to draw our ripples on the canvas, and use it as a texture in a postprocessing shader.

让我们使用画布作为纹理,因此命名为WaterTexture 。 我们将在画布上绘制涟漪图,并将其用作后期处理着色器中的纹理。

First, let's make a texture using our canvas and refresh/update that texture at the end of every update:

首先,让我们使用画布制作纹理,并在每次更新结束时刷新/更新该纹理:

import * as THREE from 'three'
class WaterTexture(){
	initTexture(){
		...
		this.texture = new THREE.Texture(this.canvas);
	}
	update(){
        ...
		this.texture.needsUpdate = true;
	}
}

By creating a texture of our canvas, we can sample our canvas like we would with any other texture. But how is this useful to us? Our ripples are just white spots on the canvas.

通过创建画布的纹理,我们可以像使用其他任何纹理一样对画布进行采样。 但这对我们有什么用? 我们的涟漪只是画布上的白色斑点。

In the distortion shader, we're going to need the direction and intensity of the distortion for each pixel. If you recall, we already have the direction and force of each ripple. But how do we communicate that to the shader?

在失真着色器中,我们将需要每个像素的失真方向和强度。 回想一下,我们已经掌握了每个波纹的方向和作用力。 但是我们如何将其传达给着色器?

在颜色通道中编码数据 (Encoding data in the color channels)

Instead of thinking of the canvas as a place where we draw happy little clouds, we are going to think about the canvas' color channels as places to store our data and read them later on our vertex shader.

与其将画布视为绘制快乐的小云的地方,不如将画布的颜色通道视为存储数据并稍后在顶点着色器上读取它们的地方。

In the Red and Green channels, we'll store the unit vector of the ripple. In the Blue channel, we'll store the intensity of the ripple.

在红色和绿色通道中,我们将存储波纹的单位矢量。 在蓝色通道中,我们将存储波纹强度。

Since RGB channels range from 0 to 255, we need to send our data that range to normalize it. So, we'll transform the unit vector range (-1 to 1) and the intensity range (0 to 1) into 0 to 255.

由于RGB通道的范围是0到255,因此我们需要发送该范围的数据以对其进行归一化。 因此,我们将单位矢量范围(-1到1)和强度范围(0到1)转换为0到255。

class WaterEffect {
    drawPoint(point){
		...
        
		// Insert data to color channels
        // RG = Unit vector
        let red = ((point.vx + 1) / 2) * 255;
        let green = ((point.vy + 1) / 2) * 255;
        // B = Unit vector
        let blue = intensity * 255;
        let color = `${red}, ${green}, ${blue}`;

        
        let offset = this.size * 5;
        ctx.shadowOffsetX = offset; 
        ctx.shadowOffsetY = offset; 
        ctx.shadowBlur = radius * 1; 
        ctx.shadowColor = `rgba(${color},${0.2 * intensity})`; 

        this.ctx.beginPath();
        this.ctx.fillStyle = "rgba(255,0,0,1)";
        this.ctx.arc(pos.x - offset, pos.y - offset, radius, 0, Math.PI * 2);
        this.ctx.fill();
    }
}

Note: Remember how we painted the canvas black? When our shader reads that pixel, it's going to apply a distortion of 0, only distorting where our ripples are painting.

注意:还记得我们是如何将画布涂成黑色的吗? 当我们的着色器读取该像素时,它将应用0的变形,仅扭曲绘制涟漪的位置。

Look at the pretty color our beautiful data gives the ripples now!

看看漂亮的颜色,我们的漂亮数据现在会产生涟漪!

演示地址

With that, we're finished with the ripples. Next, we'll create our scene and apply the distortion to the result.

这样,我们就结束了涟漪。 接下来,我们将创建场景并将失真应用于结果。

创建一个基本的Three.js场景 (Creating a basic Three.js scene)

For this effect, it doesn't matter what we render. So, we'll only have a single plane to showcase the effect. But feel free to create an awesome-looking scene and share it with us in the comments!

对于这种效果,我们渲染什么都没有关系。 因此,我们只有一个平面来展示效果。 但是,请随时创建一个很棒的场景并在评论中与我们分享!

Since we're done with WaterTexture, don't forget to turn the debug option to false.

由于我们已经完成了WaterTexture ,所以请不要忘记将debug选项设置为false。

import * as THREE from "three";
import { WaterTexture } from './WaterTexture';

class App {
    constructor(){
        this.waterTexture = new WaterTexture({ debug: false });
        
        this.renderer = new THREE.WebGLRenderer({
          antialias: false
        });
        this.renderer.setSize(window.innerWidth, window.innerHeight);
        this.renderer.setPixelRatio(window.devicePixelRatio);
        document.body.append(this.renderer.domElement);
        
        this.camera = new THREE.PerspectiveCamera(
          45,
          window.innerWidth / window.innerHeight,
          0.1,
          10000
        );
        this.camera.position.z = 50;
        
        this.touchTexture = new TouchTexture();
        
        this.tick = this.tick.bind(this);
        this.onMouseMove = this.onMouseMove.bind(this);
        
        this.init();
    
    }
    addPlane(){
        let geometry = new THREE.PlaneBufferGeometry(5,5,1,1);
        let material = new THREE.MeshNormalMaterial();
        let mesh = new THREE.Mesh(geometry, material);
        
        window.addEventListener("mousemove", this.onMouseMove);
        this.scene.add(mesh);
    }
    init(){
    	this.addPlane(); 
    	this.tick();
    }
    render(){
        this.renderer.render(this.scene, this.camera);
    }
    tick(){
        this.render();
        this.waterTexture.update();
        requrestAnimationFrame(this.tick);
    }
}

将变形应用于渲染的场景 (Applying the distortion to the rendered scene)

We are going to use postprocessing to apply the water-like effect to our render.

我们将使用后处理将水状效果应用于渲染。

Postprocessing allows you to add effects or filters after (post) your scene is rendered (processing). Like any kind of image effect or filter you might see on snapchat or Instagram, there is a lot of cool stuff you can do with postprocessing.

后处理允许您在渲染(处理)场景之后(后)添加效果或滤镜。 就像您在Snapchat或Instagram上可能会看到的任何图像效果或滤镜一样,您可以使用后处理来处理很多有趣的事情。

For our case, we'll render our scene normally with a RenderPass, and apply the effect on top of it with a custom EffectPass.

对于我们的情况,我们将使用RenderPass正常渲染场景,并使用自定义EffectPass在其顶部应用效果。

Let's render our scene with postprocessing's EffectComposer instead of the Three.js renderer.

让我们使用后处理的EffectComposer而不是Three.js renderer来渲染场景。

Note that EffectComposer works by going through its passes on each render. It doesn't render anything unless it has a pass for it. We need to add the render of our scene using a RenderPass:

需要注意的是EffectComposer的工作原理是通过它去passes上的每个渲染。 除非有通行证,否则它不会渲染任何东西。 我们需要使用RenderPass添加场景的渲染

import { EffectComposer, RenderPass } from 'postprocessing'
class App{
    constructor(){
        ...
		this.composer = new EffectComposer(this.renderer);
         this.clock = new THREE.Clock();
        ...
    }
    initComposer(){
        const renderPass = new RenderPass(this.scene, this.camera);
    
        this.composer.addPass(renderPass);
    }
    init(){
    	this.initComposer();
    	...
    }
    render(){
        this.composer.render(this.clock.getDelta());
    }
}

Things should look about the same. But now we start adding custom postprocessing effects.

事情应该看起来差不多。 但是现在我们开始添加自定义后处理效果。

We are going to create the WaterEffect class that extends postprocessing's Effect. It is going to receive the canvas texture in the constructor and make it a uniform in its fragment shader.

我们将创建WaterEffect类,以扩展后处理的Effect 。 它将在构造函数中接收画布纹理,并使其在片段着色器中成为统一的。

In the fragment shader, we'll distort the UVs using postprocessing's function mainUv using our canvas texture. Postprocessing is then going to take these UVs and sample our regular scene distorted.

在片段着色器中,我们将使用画布纹理使用后处理的函数mainUv使UV变形。 然后,后处理将采用这些UV并采样扭曲的常规场景。

Although we'll only use postprocessing's mainUv function, there are a lot of interesting functions you can use. I recommend you check out the wiki for more information!

尽管我们仅使用后处理的mainUv函数,但是您可以使用许多有趣的函数。 我建议您查看Wiki以获取更多信息!

Since we already have the unit vector and intensity, we only need to multiply them together. But since the texture values are normalized we need to convert our unit vector from a range of 1 to 0, into a range of -1 to 0:

由于我们已经有了单位矢量和强度,因此我们只需要将它们相乘即可。 但是由于纹理值已标准化,我们需要将单位矢量从1到0转换为-1到0:

import * as THREE from "three";
import { Effect } from "postprocessing";

export class WaterEffect extends Effect {
  constructor(texture) {
    super("WaterEffect", fragment, {
      uniforms: new Map([["uTexture", new THREE.Uniform(texture)]])
    });
  }
}
export default WaterEffect;

const fragment = `
uniform sampler2D uTexture;
#define PI 3.14159265359

void mainUv(inout vec2 uv) {
        vec4 tex = texture2D(uTexture, uv);
		// Convert normalized values into regular unit vector
        float vx = -(tex.r *2. - 1.);
        float vy = -(tex.g *2. - 1.);
		// Normalized intensity works just fine for intensity
        float intensity = tex.b;
        float maxAmplitude = 0.2;
        uv.x += vx * intensity * maxAmplitude;
        uv.y += vy * intensity * maxAmplitude;
    }
`;

We'll then instantiate WaterEffect with our canvas texture and add it as an EffectPass after our RenderPass. Then we'll make sure our composer only renders the last effect to the screen:

然后,我们将使用画布纹理实例化WaterEffect ,并将其添加为RenderPass之后的EffectPass。 然后,请确保我们的作曲家仅将最后一个效果渲染到屏幕上:

import { WaterEffect } from './WaterEffect'
import { EffectPass } from 'postprocessing'
class App{
    ...
	initComposer() {
        const renderPass = new RenderPass(this.scene, this.camera);
        this.waterEffect = new WaterEffect(  this.touchTexture.texture);

        const waterPass = new EffectPass(this.camera, this.waterEffect);

        renderPass.renderToScreen = false;
        waterPass.renderToScreen = true;
        this.composer.addPass(renderPass);
        this.composer.addPass(waterPass);
	}
}

And here we have the final result!

在这里,我们得到了最终结果!

演示地址

An awesome and fun effect to play with!

可以玩的很棒而有趣的效果!

结论 (Conclusion)

Through this article, we've created ripples, encoded their data into the color channels and used it in a postprocessing effect to distort our render.

通过本文,我们创建了涟漪图,将其数据编码到颜色通道中,并在后处理效果中使用它来扭曲渲染。

That's a lot of complicated-sounding words! Great work, pat yourself on the back or reach out on Twitter and I'll do it for you

这听起来很复杂! 很棒的工作,轻拍一下自己的身后,或者在Twitter上伸出手,我会为您服务

But there's still a lot more to explore:

但是,还有更多值得探索的地方:

  1. Drawing the ripples with a hollow circle

    用空心圆画出涟漪
  2. Giving the ripples an actual radial-gradient

    给波纹一个实际的径向梯度
  3. Expanding the ripples as they get older

    随着年龄的增长逐渐扩大涟漪
  4. Or using the canvas as a texture technique to create interactive particles as in Bruno's article.

    或者像Bruno的文章中那样使用画布作为纹理技术来创建交互式粒子。

We hope you enjoyed this tutorial and had a fun time making ripples. If you have any questions, don't hesitate to comment below or on Twitter!

我们希望您喜欢本教程,并祝您玩得开心。 如有任何疑问,请随时在下面或在Twitter上发表评论!

翻译自: https://tympanus.net/codrops/2019/10/08/creating-a-water-like-distortion-effect-with-three-js/

three.js创建光线

你可能感兴趣的:(webgl,opengl,分布式存储,canvas,shader)