muduo网络库剖析——监听者EpollPoller类

news2024/11/27 10:35:54

muduo网络库剖析——监听者EpollPoller类

  • 前情
    • 从muduo到my_muduo
  • 概要
    • epoll原理解析
    • epoll提供的接口
    • epoll的触发模式
    • epoll实现多路复用
  • 框架与细节
    • 成员
    • 函数
    • 使用方法
  • 源码
  • 结尾

前情

从muduo到my_muduo

作为一个宏大的、功能健全的muduo库,考虑的肯定是众多情况是否可以高效满足;而作为学习者,我们需要抽取其中的精华进行简要实现,这要求我们足够了解muduo库。

做项目 = 模仿 + 修改,不要担心自己学了也不会写怎么办,重要的是积累,学到了这些方法,如果下次在遇到通用需求的时候你能够回想起之前的解决方法就够了。送上一段话!

在这里插入图片描述

概要

转自夏天匆匆2过。

epoll原理解析

从socket接收网络数据说起:
1、网络传输中,网卡会把接收到的数据写入内存,网卡向 CPU 发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据。
2、进程执行socket()函数创建socket,这个socket 对象包含了发送缓冲区、接收缓冲区与等待队列等成员,等待队列指向所有需要等待该 Socket 事件的进程。
3、假设上面socket进程为A,另外内核还有进程B和C,内核会分时执行运行状态的ABC进程。
4、当程序执行到 Recv 时,操作系统会将进程 A 从工作队列移动到该 Socket 的等待队列中,A进程被阻塞,不会往下执行代码,也就不会占用CPU资源,此时内核只剩B和C进程分时执行。
5、一个socket 对应着一个端口号,而网络数据包中包含了 IP 和端口的信息,内核可以通过端口号找到对应的socket。
6、当socket 接收到数据后,操作系统将该socket 等待队列上的进程重新放回到工作队列,该进程变成运行状态,继续执行代码。同时由于 socket 的接收缓冲区已经有了数据,Recv 可以返回接收到的数据。

epoll的设计思路:
服务服务器需要管理多个客户端连接,而Recv 只能监视单个socket,epoll 的诞生就是高效地监视多个socket。
epoll是select 和poll的增强版本,epoll的改进:
1、epoll将“维护等待队列”和“阻塞进程“分离,先用 epoll_create 创建一个epoll 对象 Epfd,再通过 epoll_ctl 将需要监视的socket 添加到 Epfd 中,最后调用 epoll_wait 等待数据。
2、内核维护一个“就绪列表”Rdlist ,引用收到数据的 Socket,当进程被唤醒后,只要获取 Rdlist 的内容,就能够知道哪些 Socket 收到数据。

epoll的工作流程
1、当某个进程调用 epoll_create 方法时,内核会创建一个 eventpoll 对象(Epfd),eventpoll 对象是文件系统中的一员,有等待队列。Rdlist 是eventpoll的成员。
2、创建 Epoll 对象后,可以用 epoll_ctl 添加或删除所要监听的 Socket,内核会将 eventpoll 添加到这个 Socket 的等待队列中。当 Socket 收到数据后,中断程序会操作 eventpoll 对象,而不是直接操作进程。
3、当 Socket 收到数据后,中断程序会给 eventpoll 的就绪列表Rdlist 添加这个Socket 引用。eventpoll 对象相当于 Socket 和进程之间的中介,Socket 的数据接收并不直接影响进程,而是通过改变 eventpoll 的就绪列表来改变进程状态。当程序执行到 epoll_wait 时,如果 Rdlist 已经引用了 Socket,那么 epoll_wait 直接返回,如果 Rdlist 为空,阻塞进程。
4、假设计算机正在运行进程 A 和进程 B,在某时刻进程 A 运行到了 epoll_wait 语句。 内核会将进程 A 放入 eventpoll 的等待队列中,阻塞进程。当 Socket 接收到数据,中断程序一方面修改 Rdlist,另一方面唤醒 eventpoll 等待队列中的进程,进程 A 再次进入运行状态。因为 Rdlist 的存在,进程 A 可以知道哪些 Socket 发生了变化。

epoll数据结构
eventpoll结构体包含了 Lock、MTX、WQ(等待队列)与 Rdlist 等成员。
就绪列表Rdlist:是一种能够快速插入和删除的数据结构,Epoll 使用双向链表来实现就绪队列。
索引结构RBR:epoll使用红黑树作为索引结构来保存监听的socket列表。

在这里插入图片描述

epoll提供的接口

1、调用epoll_create建立epoll对象,创建一个eventpoll结构体,包括rbr(在内核cache里创建红黑树用于存储以后epoll_ctl传来的socket)和rdllist(用于存储准备就绪事件的向链表)。

//创建一个epoll实例(本质是红黑树),也占用个文件描述符,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
//返回值size,用来告诉内核这个监听的数目一共有多大,自从Linux 2.6.8开始,size参数被忽略,但是依然要大于0。
int epoll_create(int size);
struct eventpoll {
  ...
  /*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,
  也就是这个epoll监控的事件*/
  struct rb_root rbr;
  /*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/
  struct list_head rdllist;
  ...
};

2、调用epoll_ctl向epoll对象中添加或删除socket事件,所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系,向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。

/**
 * @brief 将监听的文件描述符添加到epoll对象中
 * @param epfd epoll_create的返回值,epoll对象
 * @param op   要执行的动作:EPOLL_CTL_ADD:注册新的fd到epfd中;
                           EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
                           EPOLL_CTL_DEL:从epfd中删除一个fd;

 * @param fd   要执行动作的fd
 * @param event告诉内核需要监听什么事件,epoll_event结构体:
 *     struct epoll_event {
            __uint32_t events; // Epoll events
            epoll_data_t data; // User data variable
        };
        events可以是以下几个宏的集合(常用的IN/OUT/ERR/ET):
            EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
            EPOLLOUT:表示对应的文件描述符可以写;
            EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
            EPOLLERR:表示对应的文件描述符发生错误;
            EPOLLHUP:表示对应的文件描述符被挂断;
            EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
            EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
        epoll_data_t联合体定义如下:(注意是联合体)
            typedef union epoll_data
            {
              void *ptr;		//可以传递任意类型数据,常用来传 回调函数
              int fd;		//可以直接传递客户端的fd
              uint32_t u32;
              uint64_t u64;
            } epoll_data_t;

 * @return 返回值:成功返回0。发生错误时返回-1并设置errno
 */
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 

3、当epoll_wait调用时,观察rdllist双向链表里有没有数据。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。

/**
 * @brief           等待epoll事件从epoll实例中发生
 * @param epfd      等待的监听描述符,也就是哪个池子中的内容
 * @param events    出参,指针,指向epoll_event的数组,监听描述符中的连接描述符就绪后,将会依次将信息填入
 * @param maxevents 表示每次能处理的最大事件数,告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size
 * @param timeout   等待时间,要是有连接描述符就绪,立马返回,如果没有,timeout时间后也返回,单位是ms;(超时情况下,0会立即返回,-1将不确定,也有说法说是永久阻塞)
 * @return          成功返回为请求的I / O准备就绪的文件描述符的数目,如果在请求的超时毫秒内没有文件描述符准备就绪,则返回零。发生错误时,epoll_wait()返回-1并正确设置errno。
 */
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout); 

epoll的触发模式

epoll的两种触发模式:
边沿触发vs水平触发
epoll事件有两种模型,边沿触发:edge-triggered (EPOLLET), 水平触发:level-triggered (EPOLLLT)
水平触发(level-triggered),是epoll的默认模式
socket接收缓冲区不为空 有数据可读 读事件一直触发
socket发送缓冲区不满 可以继续写入数据 写事件一直触发
边沿触发(edge-triggered)
socket的接收缓冲区状态变化时触发读事件,即空的接收缓冲区刚接收到数据时触发读事件
socket的发送缓冲区状态变化时触发写事件,即满的缓冲区刚空出空间时触发读事件
边沿触发仅触发一次,水平触发会一直触发。
开源库:libevent 采用水平触发, nginx 采用边沿触发。

epoll实现多路复用

使用一个进程(线程)同时监控若干个文件描述符读写情况,这种读写模式称为多路复用。
多用于TCP的服务端,用于监控客户端的连接和数据的发送。
优点:不需要频繁地创建、销毁进程,从而节约了内存资源、时间资源,也避免了进程之间的竞争、等待。
缺点:要求单个客户端的任务不能太过于耗时,否则其它客户端就会感知到卡顿。
适合并发量高、但是任务量短小的情景,例如:Web服务器。

epoll就是为实现多路复用而生,一个epoll线程可同时监听多个fd收发、tcp服务监听、异常事件监听等。

框架与细节

对于EpollPoller,主要是使用epoll家族来进行监听与对channel的控制。

成员

在这里插入图片描述
创建要用到的epoll文件描述符,以及events的监听事件列表。

函数

在这里插入图片描述
epoll_create1可以传入一个flag,这里调用EPOLL_CLOEXEC,和SOCK_CLOEXEC一样,关闭新进程的继承效果。
在这里插入图片描述
析构重写,调用close函数,关闭epoll文件描述符。

在poll函数中,主要使用了epoll_wait函数监听准备好的事件,以及调用了fillactiveChannels来准备激活的channel列表。下面是对epoll_wait函数的一段具体解释。并且给epoll_wait函数设定了timeOut时间,超过该时间就结束等待,返回相应的值。
在这里插入图片描述
对于updatechannel函数,给channel设置了三种状态,kNew,kAdded,kDeleted,分别代表未注册到Poller上,已注册到Poller上,已从Poller上删除。针对这三种状态,对相应的哈希表进行修改。在这里我对为什么muduo源码选择实现了vector的channel列表和哈希表的channel列表有一些理解。vector其实是监听到的激活的channel通道集合,哈希表则是是否这个channel还注册在Poller上面,或者是已经从Poller上消失了。那这么看可能vector的size会比哈希表的小,虽然这只是猜测,没有验证过。对于相应的事件,会调用update去更新通道。
在这里插入图片描述
removechannel其实也是对哈希表的channel通道集合进行一些处理,包括状态的转换。
在这里插入图片描述
对于update,就是更改channel对应的event。
在这里插入图片描述
fillactivechannels就是建立监听到的events列表与channel列表之间的联系,这样channel在之后的更新状态或删除都可以访问到对应的event。
在这里插入图片描述

使用方法

源码

//EpollPoller.h
#pragma once

#include <sys/epoll.h>

#include "Poller.h"
#include "EventLoop.h"
#include "string.h"
#include "Log.h"

class Channel;

class EpollPoller : public Poller {
public:
    EpollPoller(EventLoop* loop);
    ~EpollPoller() override;
    // 重写父类的函数
    Timestamp poll(int timeoutMs, ChannelList* activeChannels) override;
    void updateChannel(Channel* channel) override;
    void removeChannel(Channel* channel) override;
    
private:
    static const int kInitEventListSize = 16;
    using EventList = std::vector<epoll_event>; //自己用,为私有
    void update(int operation, Channel* channel);
    void fillActiveChannels(int numEvents, ChannelList* activeChannels) const;
    int epollfd_;
    EventList events_;
};

//EpollPoller.cc
#include "EpollPoller.h"

//实现channel与epoll_event一一映射

enum status {
    kNew, //channel 未添加到 Poller 中
    kAdded,     //channel 已添加到 Poller 中
    kDeleted,    //channel 从 Poller 中删除
};

EpollPoller::EpollPoller(EventLoop* loop) : Poller(loop), epollfd_(::epoll_create1(EPOLL_CLOEXEC)), events_(kInitEventListSize) {
    if (epollfd_ < 0) {
        LOG_FATAL("%s--%s--%d--%d : epoll_create error\n", __FILE__, __FUNCTION__, __LINE__, errno);
    }
}

EpollPoller::~EpollPoller() {
    ::close(epollfd_);
}

Timestamp EpollPoller::poll(int timeoutMs, ChannelList* activeChannels) {   //设置channel感兴趣的事件
    int numEvent = ::epoll_wait(epollfd_, &*events_.begin(), events_.size(), timeoutMs);
    Timestamp now = Timestamp::now();
    int saveErrno = errno;
    if (numEvent < 0) {
        if (saveErrno != EINTR) { //中断
            errno = saveErrno;
            LOG_FATAL("%s--%s--%d--%d : epoll_wait error\n", __FILE__, __FUNCTION__, __LINE__, errno);
        }
    }
    else if (numEvent == 0) {
        LOG_INFO("%s--%s--%d : epoll_wait timeout\n", __FILE__, __FUNCTION__, __LINE__);
    }
    else {
        LOG_INFO("%s--%s--%d : epoll_wait %d events happened\n", __FILE__, __FUNCTION__, __LINE__, numEvent);
        fillActiveChannels(numEvent, activeChannels);
        if (numEvent == events_.size()) {
            events_.resize(numEvent * 2);
        }
    }
    return now;
}

void EpollPoller::updateChannel(Channel* channel) { //通过改变channel来改变对应的epoll_event
    int status = channel->status();
    if (status == kNew || status == kDeleted) {
        if (status == kNew) {
            int fd = channel->fd();
            channels_[fd] = channel;
        }
        channel->set_status(kAdded);
        update(EPOLL_CTL_ADD, channel);
    }
    else {  //channel已注册到Poller上了
        int fd = channel->fd();
        if (channel->isNoneEvent()) {
            update(EPOLL_CTL_DEL, channel);
            channel->set_status(kDeleted);  //只是不监听了
        }
        else {
            update(EPOLL_CTL_MOD, channel);
        }
    }
}

void EpollPoller::removeChannel(Channel* channel) {
    int fd = channel->fd();
    channels_.erase(fd);
    int status = channel->status();
    if (status == kAdded) {
        update(EPOLL_CTL_DEL, channel);
    }
    channel->set_status(kNew);
}

void EpollPoller::update(int operation, Channel* channel) { //epoll_ctl,对指定的channel进行修改
    epoll_event event;
    memset(&event, 0, sizeof event);
    event.events = channel->events();
    event.data.fd = channel->fd();
    event.data.ptr = channel;
    if (::epoll_ctl(epollfd_, operation, channel->fd(), &event) == -1) {
        if (operation == EPOLL_CTL_DEL) {
            LOG_ERROR("%s--%s--%d--%d : epoll_ctl error\n", __FILE__, __FUNCTION__, __LINE__, errno);
        }
        else {
            LOG_FATAL("%s--%s--%d--%d : epoll_ctl error\n", __FILE__, __FUNCTION__, __LINE__, errno);
        }
    }
}

void EpollPoller::fillActiveChannels(int numEvents, ChannelList* activeChannels) const {
    for (int i = 0; i < numEvents; i++) {
        Channel* channel = static_cast<Channel*>(events_[i].data.ptr);
        channel->set_revents(events_[i].events);    //channel和event之间建立了连接
        activeChannels->push_back(channel);
    }
}

结尾

以上就是监听者EpollPoller类的相关介绍,以及我在进行项目重写的时候遇到的一些问题,和我自己的一些心得体会。发现写博客真的会记录好多你的成长,而且对于一个好的项目,写博客也是证明你确实有过深度思考,并且在之后面试或者工作时遇到同样的问题能够进行复盘的一种有效的手段。所以,希望uu们也可以像我一样,养成写博客的习惯,逐渐脱离菜鸡队列,向大佬前进!!!加油!!!

也希望我能够完成muduo网络库项目的深度学习与重写,并在功能上能够拓展。也希望在完成这个博客系列之后,能够引导想要学习muduo网络库源码的人,更好地探索这篇美丽繁华的土壤。致敬chenshuo大神!!!

鉴于博主只是一名平平无奇的大三学生,没什么项目经验,所以可能很多东西有所疏漏,如果有大神发现了,还劳烦您在评论区留言,我会努力尝试解决问题!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1393054.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

低代码配置-属性配置面板设计

模块设计 tab项切换 组件基础属性组件数据属性组件事件属性表单属性 模块输出函数设计 tab切换函数 列表表单属性 数据来源&#xff1a; 调用接口时一次赋予&#xff0c;无需使用selectItem&#xff0c;如需使用&#xff0c;归入基础属性列表标题是否展示筛选区域 示例&am…

前端框架前置学习Webpack(1) 常用webpack配置

什么是Webpack? 定义 本质上,Webpack是用于现代JavaScript应用程序的静态模块打包工具.当webpack处理应用程序时,它会在内部从一个或多个入口点构建一个依赖图(dependency graph),然后将你项目中所需的每一个模块组合成一个或多个bundles,它们均为静态资源,用于展示你的内容.…

数学建模--论文

内容来自数学建模BOOM&#xff1a;【快速入门】北海&#xff1a;数模建模基础MATLAB入门论文写作数学模型与算法(推荐数模美赛国赛小白零基础必看教程)_哔哩哔哩_bilibili 目录 一、论文整体模版 1.整体框架 2.示例 二、标题 1.标题主题事项 三、摘要 1.摘要三要素&am…

LaTeX 多栏文档 Multiple columns如何插入图片并修改样式

在今天写报告的时候用到了 latex 的多栏列表&#xff0c;插入图片的时候感觉很无助 如果不喜欢让Latex自动安排图片位置&#xff0c;可以使用float包&#xff0c;然后可以使用\begin{figure}[H]。 记得提前导入这个包 \usepackage{float} 为了让我的图片的caption居中&#xf…

Go 语言中高效切片拼接和 GO 1.22 提供的新方法

Table Contents 切片拼接的必要性基本拼接方法及其局限性使用 append 函数高效拼接的策略控制容量和避免副作用利用 Go 1.22 的新特性切片动态扩容的深入理解内存重新分配与数据迁移性能优化策略结论在 Go 语言中,切片拼接是一项常见的操作,但如果处理不当,可能会导致性能问…

Verilog刷题笔记15

题目&#xff1a; An adder-subtractor can be built from an adder by optionally negating one of the inputs, which is equivalent to inverting the input then adding 1. The net result is a circuit that can do two operations: (a b 0) and (a ~b 1). See Wikipe…

[go语言]输入输出

目录 知识结构 输入 1.Scan ​编辑 2.Scanf 3.Scanln 4.os.Stdin --标准输入&#xff0c;从键盘输入 输出 1.Print 2.Printf 3.Println 知识结构 输入 为了展示集中输入的区别&#xff0c;将直接进行代码演示。 三者区别的结论&#xff1a;Scanf格式化输入&#x…

中科院罗小舟团队提出 UniKP 框架,大模型 + 机器学习高精度预测酶动力学参数

作者&#xff1a;李宝珠 编辑&#xff1a;三羊 中国科学院深圳先进技术研究院罗小舟团队提出了&#xff0c;基于酶动力学参数预测框架 (UniKP)&#xff0c;实现多种不同的酶动力学参数的预测。 众所周知&#xff0c;生物体内的新陈代谢是通过各种各样的化学反应来实现的。这…

SpringBoot 统计API接口用时该使用过滤器还是拦截器?

统计请求的处理时间&#xff08;用时&#xff09;既可以使用 Servlet 过滤器&#xff08;Filter&#xff09;&#xff0c;也可以使用 Spring 拦截器&#xff08;Interceptor&#xff09;。两者都可以在请求处理前后插入自定义逻辑&#xff0c;从而实现对请求响应时间的统计。 …

Modelsim SE 10.5安装教程

ModelSim 是一种功能强大的硬件描述语言 (HDL&#xff0c;Hardware Description Language) 仿真和验证工具&#xff0c;可以单独仿真&#xff0c;也可以联合Quartus/Vivado等软件联合仿真&#xff0c;仿真速度快&#xff0c;广泛应用于数字电路设计和验证领域。 大学老师爱教VH…

JavaWeb后端——Maven

maven主要服务于基于Java平台的项目构建、依赖管理和项目信息管理 maven项目对象模型简称POM&#xff0c; maven解决问题&#xff1a; 1. 添加第三方jar包&#xff0c;maven将 jar 包放在本地仓库中统一管理&#xff0c;使用时用坐标的方式引用即可 2. 解决 jar 包之间的依…

计算机网络-计算机网络的概念 功能 发展阶段 组成 分类

文章目录 计算机网络的概念 功能 发展阶段总览计算机网络的概念计算机网络的功能计算机网络的发展计算机网络的发展-第一阶段计算机网络的发展-第二阶段-第三阶段计算机网络的发展-第三阶段-多层次ISP结构 小结 计算机网络的组成与分类计算机网络的组成计算机网络的分类小结 计…

springBoot 添加自定义类库包

一、新建SpringBoot Web 二、添加类库包 com.saas.pdf 删除掉多余的类&#xff0c;新建类&#xff1a;PdfUtil.java package com.saas.pdf;public class PdfUtil {public static void Save(String filePath) {System.out.println("保存成功&#xff01;");} }三、…

阿里云服务器4核8G配置收费标准及新老用户优惠价格整理

阿里云服务器4核8g配置云服务器u1价格是955.58元一年&#xff0c;4核8G配置还可以选择ECS计算型c7实例、计算型c8i实例、计算平衡增强型c6e、ECS经济型e实例、AMD计算型c8a等机型等ECS实例规格&#xff0c;规格不同性能不同&#xff0c;价格也不同&#xff0c;阿里云服务器网al…

十二、Qt 操作PDF文件(2)

一、在《十、Qt 操作PDF文件-CSDN博客》中我们用Poppler类库打开了PDF文件&#xff0c;并显示到窗体上&#xff0c;但只能显示一页&#xff0c;功能还没完善&#xff0c;在本章节中&#xff0c;加入了&#xff1a; 通过选择框选择PDF文件并打开&#xff0c;默认打开第一页。通…

最新内置30+远程接口,全新API接口管理系统PHP源码,附带系统搭建教程

搭建教程 内置30远程接口doc文件夹可参考自行编辑api文件夹里附赠qrcode接口源码 此程序基于ThinkPHP5.1 PHP版本需7.0-7.3之间。 Nginx请设置如下TP伪静态 Apache无需配置 运行目录默认即可 将程序上传至网站根目录,访问域名/install进行安装操作

IP定位助力网络安全防线

随着互联网技术的飞速发展&#xff0c;网络安全问题日益凸显。在网络安全领域&#xff0c;IP地址定位技术正发挥着越来越重要的作用&#xff0c;成为维护网络安全的一道有力防线。 一、追踪黑客攻击者&#xff0c;维护公共安全 在网络安全领域&#xff0c;黑客攻击是一个严重的…

Phantomjs+Java+springboot实现后端截图

一、phantomjs介绍 &#xff08;1&#xff09;一个基于webkit内核的无界面浏览器&#xff0c;即没有UI界面&#xff0c;即它就是一个浏览器&#xff0c;只是其内的点击、翻页等人为相关操作需要程序设计实现。 &#xff08;2&#xff09;提供javascript API接口&#xff0c;即通…

路由器初始化配置、功能配置

实验环境 拓扑图 Ip规划表&#xff08;各组使用自己的IP规划表&#xff09; 部门 主机数量 网络地址 子网掩码 网关 可用ip Vlan 市场部 38 192.168.131.0 255.255.255.0 192.168.131.1 2-254 11 研发部 53 192.168.132.0 255.255.255.0 192.168.132.1 2-2…

Three.js Tri-panner (三面贴图) 材质 两种实现方式

文章目录 介绍自定义shaderNodeMaterial骨骼材质特殊处理 介绍 Tri-panner 在babylonjs中有支持 但是three.js目前的基础材质并不支持 需要自己定义shader 或者使用目前还没有什么完善的文档的 NodeMaterial 下面展示两种实现方式 自定义shader /*** description: 替换三角面…