初学webgl的朋友可能觉得它的绘图过程非常麻烦,绘图逻辑也不像canvas2D那般直接,这与GPU渲染管线的硬件结构有直接关系,下面结合webgl代码对渲染管线的硬件进行一个大概的梳理。
1、webgl程序
网上webgl的示例代码已经很多,所以我这里只列出关键部分,文章主要的篇幅放在硬件结构的介绍上。
【设置数据】设置顶点的坐标、对应纹理坐标、变换矩阵等数据。
js
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,1,0,...]));
【设置纹理使用的滤波器】
js
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR_MIPMAP_NEAREST);
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGB,gl.RGB,gl.UNSIGNED_BYTE,image);
【设置着色器】添加着色器代码,进行编译和链接。
js
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, `
attribute vec4 aVertexPosition;
void main(void) {
gl_Position = aVertexPosition;
}
`);
gl.compileShader(vertexShader); // 编译着色器
const fragmentShader = gl.createShader(gl.VERTEX_SHADER);// 像素着色器,同上...
...
const shaderProgram = gl.createProgram();
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
【设置参数并执行绘制】抗锯齿、深度测试、背面剔除、模板测试、混合、裁剪等相关控制参数。
js
canvas.getContext('webgl', { antialias: true }); //开启抗锯齿
gl.enable(gl.DEPTH_TEST); //启用深度测试
gl.cullFace(gl.BACK); //剔除背面三角形
gl.enable(gl.STENCIL_TEST); //启用模板测试
gl.enable(gl.BLEND); //开启混合
gl.enable(gl.SCISSOR_TEST); //使用裁剪
// 绘制
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 3);
2、webgl执行过程
因为webgl是参考OpenGL ES
的,而OpenGL ES
又是OpenGL
的一个子集,它们能查到的知识更多,所以webgl相关知识部分我多是参考了后两者的。
webgl
主程序在CPU上运行,对于顶点着色器代码、片段着色器代码会调用系统中的Driver
(驱动程序 )进行编译。这个驱动是对应的GPU厂商提供,驱动中通常封装了调用GPU能力的接口和编译器。
顶点、纹理、透视矩阵等数据,以及编译后的着色器和webgl
各api
对应的指令 (图元装配、各种模式设置等),都会经过一条总线发送到GPU中执行(pcie
是CPU与GPU连接中常用总线的一种)
因为主程序端可能会经常修改模式 和状态 (混合、背面剔除等),所以webgl还将多数命令都存放到一个显示列表中发送到GPU,避免每次绘制时都要从CPU传输。
上图右侧是多数GPU中渲染管线的一个常见结构,各阶段作用如下:
几何阶段:
Vertex Shading
:顶点着色器程序执行,对每个传入的顶点进行投影、变换等操作,得到处理后的顶点坐标。Primitive Assembly
(图元装配):将所给的顶点数据装配出图元(比如给定的a,b,c
3个点算一个三角形图元)。clipping
(裁剪):将指定的平面和可视区域范围外的顶点裁剪丢弃,以免不必要的渲染。tiling
(分块):将窗口中的所有像素按照指定大小分块(tile
),分块中包含多个图元,它们单独进行处理。
光栅化阶段:
Backface Culling
(背面剔除):启用了背面剔除后,背面三角形将不再参与后续的处理。Triangle Setup
(三角形设置):计算三角形顶点坐标和边界表达式,并确定丢弃哪些tile
。Rasterization
(光栅化):将图元转化为屏幕上对应的具体像素,并做更小的分块处理。Interpolation
(插值):根据各图元的顶点对它们进行属性插值,生成中间各像素点对应的坐标、纹理坐标、颜色等。
片段阶段:
Early-Z test
:在像素着色器之前进行的一个深度测试,被遮挡住的像素点不再参与后续执行。Pixel Shading
:像素着色器程序执行(光照计算、纹理获取通常在这一步进行)输出当前像素对应的颜色。Depth/Stencil Test
:对像素着色运行结果先进行深度测试,通过的再进行模板测试。Blending
:对设置了透明的像素会与其后面的像素进行插值,生成最终的像素颜色。
3、GPU硬件结构
由于各大GPU厂商稍高端的GPU架构并不开源,所以对于GPU硬件结构一块我们只能选一些已开源的GPU架构进行研究。
而从开源的架构中选呢又有个麻烦的点,多数已开源架构限于能力,只是偏向于某一方向,通用计算的有些完全不支持图像渲染。Vortex
倒是支持通用计算和图像渲染,也支持OpenGL
,但其图像方面仅有一个纹理处理单元,这不大合现代通用型GPU的结构。
由于诸多因素,最后考虑使用Skybox
架构作为基准,再适当拓展一些其它架构情况,选择原因如下:
- Skybox的架构更接近高端一些的通用型GPU(GPGPU)
- Skybox主要是参考
Vortex
架构实现的,结合两者,能找到的知识点更为完全。 - Skybox不支持OpenGL,且是基于图块的渲染架构,但对于各图形库的支持主要在驱动的编译和扩展指令方面实现,对其硬件结构影响不大。
Skybox介绍 :该架构于2023年提出,是一个偏向于图像渲染方面的开源GPU架构,支持Vulkan
运行,32核,512线程。其微结构图如下:
大致结构:
- 命令处理器 (
Command processor
)用于与主机CPU和本地视频内存 (video memory
)进行接口交互。 - 设备配置寄存器 (
DCRs
)用于存储固定功能单元的配置参数。 - 处理器(
Processor
)拥有多个集群 (Cluster
),每个集群可以看做一个完整的处理器。 L2 Cache
和L3 Cache
是数据和指令的多级缓存。- 光栅化单元 (
Raster Unit
):是渲染管线中光栅化的硬件实现(不可编程)。 - 渲染输出单元 (
ROP
):计算来自各核心的每个片段数据,并将它们的颜色和深度值写入到目标缓冲区。 - 各集群中有多个可编程核心 (
Core
),顶点、像素着色器代码的执行、线程的调度主要在这里进行。 - 核心外沿的纹理处理单元(
Tex
)、浮点计算单元(FPUs
)、数据和指令的缓存、共享内存(Shader Memory
)等是各核心共用的。
4、渲染管线硬件详细
这里先大致说一下GPU渲染管线这些年的变化过程,方便对它做一个全面的了解。
- 固定硬件阶段 :大概
1980~2000
这段时间,渲染管线各部分都有单独硬件对应,整个管线不可编程,只能根据api传入一些参数进行控制。更早期的GPU则只是一个图形加速器,顶点阶段甚至要在CPU上完成。 - 统一渲染架构阶段 :1999 NVIDIA 发布
GeForce 256
,引入了可编程顶点着色器和像素着色器。2000年至今,渲染管线的步骤大致统一,虽然硬件上可能差异大,但管线中各图形处理步骤基本都会支持。 - 现代高端的管线架构 :
NVIDIA, AMD
等各大厂商高端GPU中除了渲染管线、通用计算支持,还加入了管线追踪硬件加速、张量核心等技术。它们能为渲染管线提供更强的能力。
Skybox
属于统一渲染架构的设计,下面对照着渲染管线各阶段来介绍对应的硬件结构。
4.1 几何阶段
Skybox
架构中,渲染管线的几何阶段 和光栅化的部分阶段 都在核心(Core
)中以指令的方式执行 。下面是Vortex
架构核心(Core
)的微结构图:
如上图所示,核心中主要是一个五级指令流水线的实现,这也是串通整个渲染管线的一个结构。流水线各阶段分别为:Fetch
(取指)、Decode
(译码)、Issue
(发射)、Execute
(执行)、Commit
(提交)。
4.1.1 Vertex Shading
(1)取指 :首先wavefront Scheduler
(波前调度器)从指令缓存中读取指令,各指令被发送到不同的wavefront
(波前)执行。
波前调度器中的waveFront Table
用于存放各wavefront
的PC和它的波前隐码(PC用于记录当前执行到哪个指令,波前隐码用于标记波前的活动、锁定等状态)
每个wavefront
都会控制多个线程执行,一个wavefront
中的所有线程都执行相同的指令,但各线程有自己的数据路径、隐码状态等。
各wavefront
还要控制指令的拆分、合并,线程的同步、发散 (程序中的if,else
、循环等可能会被拆分为不同的线程),IPDOM Stack
用于暂存线程,便于辅助这些操作。
(2)译码:将获取到的指令译码为GPU可理解的操作,指令被拆分为一系列微指令,这些微指令再映射到硬件上。
(3)发射 :这个阶段要检查指令间的依赖性、资源的可用性,决定指令的发射先后顺序,分发指令(计算类指令要发送到ALU
还是FPU
,访存类的发送到哪个存储单元进行访存等)
记分牌 (Scoreboard
):波前调度器使用内部的记分牌机制来跟踪各种资源和依赖性,确保只有在其所有的源操作数都可用时,才会发射一条指令。
(4)执行阶段 :在具体的计算单元上执行计算(各单元具体的执行在下面介绍),或是数据的存储。Shared memory
用于各线程之间的资源共享、通信。
现代GPU中多数都支持SIMD
这种单指令同时对多数据进行操作的方式,于图像处理这样的各像素点进行相同处理的情况尤为合适,对应到渲染管道中就是同时处理多个顶点或像素点。
SIMD的实现主要是编译阶段就生成对应向量指令集 ,执行时,各指令数据存放到向量寄存器上由向量指令控制同时执行。
代理 (Agent
):Skybox
架构中引入了一些轻量级硬件组件------TEX Agent
、FPU Agent
、Raster Agent
、ROP Agent
。在遇到要进行纹理处理 、浮点计算 、光栅化 、输出渲染的请求时,对应代理将请求使用外部对应的硬件单元。
(5)提交阶段:控制将数据重新写回寄存器,便于下一轮指令使用其结果。
拓展 :在固定硬件架构的GPU中,渲染管线也大致是一个流水线的实现,只是各细小阶段都有设置硬件,如:变换与光照对应T&L
硬件实现,染色阵列USA
控制顶点染色、纹理坐标生成等。
4.1.2 图元装配与裁剪
webgl的绘图API中包含了图元装配和裁剪的指令,虽然可以指定很多的图元类型,但最终生成的只有3种------点、线、三角形。
平面裁剪:若程序中启用了裁剪,则要裁剪掉指定区域外的点。一个三角形只有部分点被裁掉的话在裁剪边界相交处还要生成新的顶点。下面是一个判断点是否在裁剪面内的算法:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> { 裁剪平面: A x + B y + C z + D = 0 若 ( A , B , C , D ) ∗ M − 1 ∗ ( x e , y e , z e , w e ) T ≥ 0 ,则当前点保留 M 为模型视图矩阵。 \begin{cases}裁剪平面:Ax+By+Cz+D=0\\ 若(A,B,C,D)*M^{-1}*(x_e,y_e,z_e,w_e)^T≥0,则当前点保留\\ M为模型视图矩阵。\end{cases} </math>⎩ ⎨ ⎧裁剪平面:Ax+By+Cz+D=0若(A,B,C,D)∗M−1∗(xe,ye,ze,we)T≥0,则当前点保留M为模型视图矩阵。
视锥体裁剪:平面裁剪之后还要对可视区域外的部分进行裁剪,裁剪方法相似,但后者涉及到六个面的裁剪。
在Skybox
架构中,图元装配、裁剪也在可编程核心(Core
)中运行(与Vertex Shading
阶段一样的执行流程)
拓展:对于固定硬件的GPU一般有图元装配单元,单元中包含多个并行的装配核心,多个顶点属性寄存器,输出图元顶点组装命令到流水线继续执行。
4.1.3 tiling分块
现代GPU常见的渲染架构有两种------立即模式渲染 和基于图块的渲染。
立即渲染模式:这种架构的渲染过程是连续的,即每个图元(点、线、三角形)一旦被提交,就会立即被光栅化和着色,不需要等待整个场景的数据(该类型架构的渲染管道内不含"tiling分块"步骤)
Skybox则使用基于图块的渲染方式,这种方式是将屏幕分割成多个小块(Tiles
),每个tile独立处理,这可以更好地利用GPU的片上缓存(on-chip memory
)。
每个Tile都会生成与之关联的数据队列,追踪所有相交的几何图元,这些tile会被分配到不同的线程上执行。这种方式减少了对内存带宽的需求,多用于移动端和嵌入式设备。
该步骤在Core
中进行。
4.1.4 背面剔除与三角形设置
三角形设置(Triangle Setup
):这一步是计算三角形网格信息,使用边缘方程计算各像素是否在三角形内,并确定哪些tile
块被丢弃。计算使用方程如下:(adj()
表示计算伴随矩阵)
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> M = [ w 0 x 0 w 1 x 1 w 2 x 2 w 0 y 0 w 1 y 1 w 2 y 2 w 0 w 1 w 2 ] , E = a d j ( M ) = [ a 0 b 0 c 0 a 1 b 1 c 1 a 2 b 2 c 2 ] M=\left[\begin{matrix}w_0x_0 & w_1x_1 & w_2x_2 \\w_0y_0 &w_1y_1 &w_2y_2\\w_0 &w_1 &w_2 \end{matrix}\right],~~ E=adj(M)=\left[\begin{matrix}a_0 &b_0 &c_0\\a_1&b_1 &c_1\\a_2 &b_2 &c_2 \end{matrix}\right] </math>M= w0x0w0y0w0w1x1w1y1w1w2x2w2y2w2 , E=adj(M)= a0a1a2b0b1b2c0c1c2
得到边的方程表示: <math xmlns="http://www.w3.org/1998/Math/MathML"> 𝐸 < 1 , 2 , 3 > = [ a < 1 , 2 , 3 > , b < 1 , 2 , 3 > , c < 1 , 2 , 3 > ] 𝐸_{<1,2,3>} = \left[ a_{<1,2,3>},b_{<1,2,3>},c_{<1,2,3>}\right] </math>E<1,2,3>=[a<1,2,3>,b<1,2,3>,c<1,2,3>]
计算像素(x,y)
: <math xmlns="http://www.w3.org/1998/Math/MathML"> 𝐸 ( 𝑥 , 𝑦 ) = [ 𝑎 , 𝑏 , 𝑐 ] [ 𝑥 , 𝑦 , 1 ] T 𝐸(𝑥, 𝑦) = \left[𝑎, 𝑏, 𝑐\right]\left[𝑥, 𝑦, 1\right]^T </math>E(x,y)=[a,b,c][x,y,1]T,使用上面三条边计算若都为负值,则表明像素(x,y)
不在该三角形中。
背面剔除 :三角形设置步骤中,各边向量(a,b)
垂直于边并指向内部半空间,该向量被用于进一步计算与视线方向的关系,若判断是背面图元,则不再进行后续处理。
Skybox
将这两个步骤都作为几何阶段,放到Core
中一起执行。
4.2 光栅化阶段
光栅化是对图形加速的主要手段,所以现代多数GPGPU都有光栅化硬件,Skybox
的光栅化单元微架构图如下:
光栅缓存 (Raster Cache
):在GPU上执行的瓦片分箱过程经过优化,可以对瓦片数据进行排序,使得共享相同图元的瓦片放置得更近,从而提高缓存命中率。
几何阶段Tiling
分块得到多个tile会存放到瓦片缓冲区 (Tile Buffer
)中,每个tile中会含有的图元的数量,并存储它们的pid
。pid值与图元缓冲区 (Primitive Buffer
)中的各图元数据对应,每条图元数据则包含对边的描述和属性数据(纹理坐标、法线向量等)
光栅内存单元 (RMU
):该单元按顺序访问Tile Buffer
以获取tile数据,并使用有限状态机(FSM)访问Primitive Buffer
以获取图元。FSM会生成内存地址,直到所有瓦片及其关联的图元都被获取。
光栅切片 (RS
):光栅切片通过递归细分瓦片,从RMU中递归遍历每个三角形图元,生成由该图元覆盖了的多个2x2
像素大小的四元组。使用图元边缘方程系数来测试当前瓦片是否覆盖该图元,并在每次细分瓦片时更新这些系数。生成的四元组被推入输出队列,以供后续阶段使用。
拓展:
- 一些架构中可能并包含光栅化硬件(如
Vortex
)这种架构中光栅化只能使用对应的一系列指令在可编程核心中执行实现。 - 稍早一些的GPU光栅化硬件中多数是包含背面剔除、三角形设置、属性插值步骤的。
4.3 片段阶段
该阶段会先使用扩展的指令vx_rast
查询光栅化器以请求像素戳。该调用被转发到光栅代理,光栅代理 将从光栅化器生成下一个像素戳保存到本地CSR
(寄存器)中,以供片段阶段使用。
4.3.1 属性插值
三角形中的各像素点对应的坐标、纹理坐标、法线等需要根据3个顶点使用线性插值 或重心插值等算法生成。
传统的渲染管线设计是将插值放到光栅化阶段,插值数据存放到寄存器中供片段着色器使用,但Skybox
中将插值放到片段阶段,使用扩展指令vx_imadd
加速属性插值,在Core
分配的线程中运行。
4.3.2 Early-Z Test
该阶段将像素深度值与深度缓冲区进行比较以检查可见性,不可见的像素点不再执行Pixel Shading
阶段。
拓展:Early-Z Test属于一个优化项,这项技术在2000年左右开始加入GPU渲染管道设计中,一般放在可编程核心中执行。
4.3.3 Pixel Shading
该阶段执行的是传入的像素着色器程序,每个像素点都会使用该着色程序执行。与Vertex Shading
阶段一样,在可编程核心(Core
)上运行。
着色结果(位置、面、颜色和深度数据)会使用扩展指令vx_rop
提交给ROP Agent
,再将结果提交到ROP
单元。
4.3.4 Depth/Stencil Test
Skybox
架构中,深度/模板测试 (Depth/Stencil Test
)与混合 会在ROP unit
进行。Rop Unit
结构图如下:
内存单元(Memory unit
)生成内存请求,检索每个输入像素的深度值、目标颜色。之后它们会被发送到深度/模板测试单元 (Depth/stencil Unit
)和混合单元 (Blend Unit
)。
Depth/stencil Unit
结构图如下:
深度测试将当前片段的深度值与其在深度缓冲区中的对应值进行比较,如果测试失败,表明片段被现有对象遮挡,则该片段被丢弃。
对于经过深度测试的值,若没有开启模板测试的则直接输出,否则要再经过模板测试操作输出结果。最终结果都要再次更新深度缓冲区。
4.3.5 混合
渲染场景中,透明的物体还需要能看到其后面的物体,这需要将两物体重合的部分颜色混合得到最终色值。混合是一个在两像素颜色/透明度值 间进行插值得到中间值的过程(颜色、透明度都要混合)
Blend Unit
结构如下:
Blend Unit
硬件在两级流水线上调度此操作,其中第一级生成混合函数,第二级实现混合方程。
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> { R = f ( R s ∗ S r , R d ∗ D r ) G = f ( G s ∗ S g , G d ∗ D g ) B = f ( B s ∗ S b , B d ∗ D b ) A = f ( A s ∗ S a , A d ∗ D a ) \begin{cases}R=f(R_s*S_r,R_d*D_r)\\G=f(G_s*S_g,G_d*D_g)\\ B=f(B_s*S_b,B_d*D_b)\\ A=f(A_s*S_a,A_d*D_a) \end{cases} </math>⎩ ⎨ ⎧R=f(Rs∗Sr,Rd∗Dr)G=f(Gs∗Sg,Gd∗Dg)B=f(Bs∗Sb,Bd∗Db)A=f(As∗Sa,Ad∗Da)
要执行的混合操作(f
)由混合模式决定,模式共有五种:加法、减法、反向减法、最小值和最大值。
𝑅𝑠, 𝐺𝑠, 𝐵𝑠, 𝐴𝑠
是当前值,𝑅𝑑, 𝐺𝑑, 𝐵𝑑, 𝐴𝑑
是帧缓冲区中对应像素色值。𝑆𝑟, 𝑆𝑔, 𝑆𝑏, 𝑆𝑎
与𝐷𝑟, 𝐷𝑔, 𝐷𝑏, 𝐷𝑎
则是它们对应的混合因子。
混合用的函数、混合因子一般都支持更换,webgl中就可以使用gl.blendFunc()
设置。
拓展:混合的最终结果会写入帧缓冲区,至于是否显示到屏幕上,一般还要传输到显存中一个特定的屏幕像素存储区域,再经过CPU调配、显示器处理等操作。
4.4 纹理滤波
纹理滤波用于减轻纹理的锯齿、模糊等情况。纹理的处理是一个常用且特殊的功能,在现代GPU中一般都有单独的硬件对应。
Skybox
中,纹理的处理是作为一个共用的功能为渲染管道提供。核心(Core
)中遇到纹理处理请求时会经过Tex Agent
向Tex Unit
单元请求进行处理。下面是Vortex
架构中的纹理单元架构图。
该单元实现了三个主要阶段:
- 纹理地址生成 :使用u、v、lod参数从CSR中检索纹理操作的相关控制信息。(其它
mipmap
等特定配置参数也从CSR传递进来)这些数据到地址生成器,经过指定滤波模式处理后得到对应像素的地址。 - 纹理存储 :得到的像素地址被放到纹理存储单元 ,在两个线程上进行去重(因为3d场景中的缩放、视角等原因,其中可能有多个
u, v
对应的是一个纹理像素点情况)。随后再经过纹理存储调度发射到数据缓存中。 - 纹理采样 :在缓存响应后,返回的纹理像素被复制传输到缓冲区中等待,之后送给纹理采样器(
Texel Sampler
)。纹理像素采样器执行格式转换和两个周期的双线性插值 ,最后经过一个RGBA
颜色过滤器后数据被发送到纹理单元,准备开始下一个批次的数据处理。
4.5 抗锯齿
生成的图像在几何体边缘,如果这些边缘线条不是45度角的整数倍时,它们可能会呈现较明显的锯齿状。
多重采样抗锯齿(MSAA
)是处理锯齿广泛采用的解决方案。Skybox中也是使用此方案,开启抗锯齿功能后,光栅化以更高的分辨率执行,且为每个像素分配多个采样点的存储空间(如颜色、深度、模板缓冲区)。
Early-z Test
、深度/模板测试等步骤这些采样点也要参与进行。最终各采样点会被加权平均,作为当前像素点的最终颜色。
拓展:MSAA为每个像素提供的采样点数一般可支持配置,但GPU可用资源不多时采样点数也会减少。抗锯齿算法众多,根据它们的原理,在GPU上实现的方式、所处的阶段可能都差异较大。
5、总结
GPU因为被用于支持图像渲染、通用计算、AI学习,它的架构体系比CPU还要多样化。所以各GPU架构可能存在许多不同。
但现代高端点的通用型GPU大致上还是集群+核心 结构,指令流水线用SIMT
加SIMD
方式,渲染管线方面额外有光栅化硬件、渲染输出硬件支持。
更高端一些的GPU则会包含光线追踪硬件 和支持深度学习类算法的张量核心,这两者都可以对渲染管线提供加速和提高渲染质量(比如NVIDIA GeForce RTX 20系列开始就逐步加入了这些技术)。另外,这些GPU的集群、核心、支持的线程数、可用硬件资源更多,有更优化的算法、结构设计等。
对于固定渲染管线硬件的GPU在一些嵌入式设备上也还有使用,不过其使用的架构设计性能不高,且支持的线程数也只有几十个。
以上依然只是渲染管线大致结构与执行过程的介绍,其中还有许多未提到和未查清的细节,但作为参考还算可用。希望这些对大家有所帮助。
6、主要参考
-
1\] Vortex: Extending the RISC-V ISA for GPGPU and 3D-Graphics Research
-
3\] 图形处理器3D引擎渲染管线设计与验证_蔡叶芳_1
-
5\] [Incremental and hierarchical Hilbert order edge equation polygon rasterizatione](https://link.juejin.cn?target=http%3A%2F%2Fwww.semanticscholar.org%2Fpaper%2F1fd653cc20ddf626aa2758c55a542d6a2ad909fb "http://www.semanticscholar.org/paper/1fd653cc20ddf626aa2758c55a542d6a2ad909fb")