BRDF理论及shader实现(下)
接上篇:
BRDF理论及shader实现(上)
Specular BRDF
对于specular分量来说,fmf_mfm是一个遵循菲涅尔反射定律的镜面BRDF项,此时的fmf_mfm满足([3]和[21]有详细的推导):
fm(l,v,m)=F(v,m)δωm(h,m)4(l⋅h)2f_m({\bf{l}},{\bf{v}},{\bf{m}}) = F({\bf{v}},{\bf{m}})\frac{\delta_{\omega_m}({\bf{h}}, {\bf{m}})}{4({\bf{l}}\cdot{\bf{h}})^2} fm(l,v,m)=F(v,m)4(l⋅h)2δωm(h,m)
h{\bf{h}}h表示half vector,是v{\bf{v}}v和l{\bf{l}}l的平均;这里分母上第一次出现了444,这也是后面specular BRDF公式的分母上的444的来源。δωm(s,o)\delta_{\omega_m}({\bf{s}}, {\bf{o}})δωm(s,o)记为:
∫Ωg(o)δωo(s,o)dωo={g(s),if s∈Ω0,otherwise\int_\Omega g({\bf{o}})\delta_{\omega_o}({\bf{s}}, {\bf{o}})d\omega_o = \begin{cases} g({\bf{s}}), & \text {if $s\in\Omega$} \\ 0, & \text{otherwise} \end{cases} ∫Ωg(o)δωo(s,o)dωo={g(s),0,if s∈Ωotherwise
此时,frf_rfr可以化简为为:
fr(l,v)=D(h,α)G(v,l,α)F(v,h,F0)4(n⋅v)(n⋅l)f_r({\bf{l}},{\bf{v}}) = \frac{D({\bf{h}},\alpha)G({\bf{v}},{\bf{l}},\alpha)F({\bf{v}},{\bf{h}},F_0)}{4({\bf{n}}\cdot{\bf{v}})({\bf{n}}\cdot{\bf{l}})} fr(l,v)=4(n⋅v)(n⋅l)D(h,α)G(v,l,α)F(v,h,F0)
其中,α\alphaα取决于我们前面提到的粗糙度roughness,具体为
α=roughness2\alpha = roughness^2 α=roughness2
可以理解为α\alphaα是对粗糙度roughness的一个映射,α\alphaα将多次被用到。
菲涅尔项及F0F_0F0会在后面详细介绍,这里暂时略过。
shader实现:
// #define saturate(x) clamp(x, 0, 1)
// N = normal;
// V = normalize(camPos - WorldPos);
// L = normalize(LightPos - WorldPos));
// H = normalize(V + L);
// NdotV = saturate(dot(N, V));
// NdotL = saturate(dot(N, L));
// NdotH = saturate(dot(N, H));
// LdotH = saturate(dot(L, H));
// VdotH = saturate(dot(V, H));
vec3 SpecularBRDF(float NdotV, float NdotL, float NdotH, float LdotH, float VdotH, vec3 F0, float roughness) {float r = roughness * roughness;float D = Distribution(NdotH, r);float V = Geometry(NdotV, NdotL, r);vec3 F = Fresnel(VdotH, F0);return D * V * F / (4 * NdotV * NdotL);
}
注意,这里的NdotL
,NdotV
等都是clamp到0到1的。
接下来具体看frf_rfr中每个分量的可能形式都有哪些。
法向分布函数 D
这一部分介绍3个法向分布函数的公式,以及一个推广。
Beckmann
来源[5],DBeckmannD_{Beckmann}DBeckmann假设微表面的法向分布是以n{\bf{n}}n为均值的高斯分布,也即h{\bf{h}}h与n{\bf{n}}n越接近,反射的光线越多,DBeckmannD_{Beckmann}DBeckmann越大。再结合DBeckmannD_{Beckmann}DBeckmann的积分约束,求得:
DBeckmann(h,α)=χ+(n,h)πα2(n⋅h)4e(n⋅h)2−1α2(n⋅h)2D_{Beckmann}({\bf{h}}, \alpha) = \frac{\chi^+({\bf{n}},{\bf{h}})}{\pi\alpha^2({\bf{n}}\cdot{\bf{h}})^4}e^{\frac{({\bf{n}}\cdot{\bf{h}})^2-1}{\alpha^2({\bf{n}}\cdot{\bf{h}})^2}} DBeckmann(h,α)=πα2(n⋅h)4χ+(n,h)eα2(n⋅h)2(n⋅h)2−1
这个公式有一个很致命的缺陷,那就是当roughness接近于1的时候,DBeckmannD_{Beckmann}DBeckmann在n⋅h∈[0,1]{\bf{n}}\cdot{\bf{h}}\in[0,1]n⋅h∈[0,1]区间内,不是单调递增的。表现在渲染上,就是在高光最强的中心点会产生一个暗斑。
shader实现:
float DistributionBeckmann(float NdotH, float r) {float NdotH2 = NdotH * NdotH;float r2 = r * r;float r2NdotH2 = r2 * NdotH2;return exp((NdotH2 - 1) / (r2NdotH2)) / (PI * r2NdotH2 * NdotH2);
}
BlinnPhong
来源[6],BlinnPhong公式纯粹是基于经验的,在恰当选取参数的情况下,它的函数曲线非常接近于Beckmann。
BlinnPhong原始的模型是:
DBlinn(h,α)=χ+(n,h)αp+22π(n⋅h)αpD_{Blinn}({\bf{h}}, \alpha) = \chi^+({\bf{n}},{\bf{h}})\frac{\alpha_p + 2}{2\pi}({\bf{n}}\cdot{\bf{h}})^{\alpha_p} DBlinn(h,α)=χ+(n,h)2παp+2(n⋅h)αp
其中,αp\alpha_pαp表示粗糙系数,或者准确的说,是光滑系数——αp\alpha_pαp越大,表示物体表面越光滑。
- 当αp=∞\alpha_p=\inftyαp=∞的时候,表示绝对光滑的物体,此时DBlinn(h,α)D_{Blinn}({\bf{h}}, \alpha)DBlinn(h,α)只有在h=n{\bf{h}} = {\bf{n}}h=n,即入射角等于出射角的时候为∞\infty∞,否则为0。
- 当αp=0\alpha_p=0αp=0的时候,表示绝对粗糙的物体,DBlinn(h,α)=1πD_{Blinn}({\bf{h}}, \alpha) = \frac{1}{\pi}DBlinn(h,α)=π1,这个式子也是后面会提到的diffuse的式子。
令αp=(2α2−2)\alpha_p = (\frac{2}{\alpha^2} - 2)αp=(α22−2),则有:
DBlinn(h,α)=χ+(n,h)πα2(n⋅h)(2α2−2)D_{Blinn}({\bf{h}}, \alpha) = \frac{\chi^+({\bf{n}},{\bf{h}})}{\pi\alpha^2}({\bf{n}}\cdot{\bf{h}})^{(\frac{2}{\alpha^2} - 2)} DBlinn(h,α)=πα2χ+(n,h)(n⋅h)(α22−2)
这个公式即接近于Beckmann的法向分布公式,也是常用的BlinnPhong形式。
shader实现:
float DistributionBlinnPhong(float NdotH, float r) {float a = r * r;return pow(NdotH, 2.0 / a - 2.0) / (PI * a);
}
GGX
来源[3],GGX是根据实测数据拟合出来的一个公式:
DGGX(h,α)=χ+(n,h)⋅α2π((n⋅h)2(α2−1)+1)2D_{GGX}({\bf{h}}, \alpha) = \frac{\chi^+({\bf{n}},{\bf{h}})\cdot\alpha^2}{\pi(({\bf{n}}\cdot{\bf{h}})^2(\alpha^2-1)+1)^2} DGGX(h,α)=π((n⋅h)2(α2−1)+1)2χ+(n,h)⋅α2
shader实现:
float DistributionGGX(float NdotH, float r) {float a2 = r * r;float NdotH2 = NdotH * NdotH;float nom = a2;float denom = (NdotH2 * (a2 - 1.0) + 1.0);denom = PI * denom * denom;return nom / max(denom, 0.001);
}
除了这三种公式,还有更多更复杂的法向分布函数D,具体可以参考[17]。但是其实最常用的还是GGX(及其各向异性模式),无论是游戏还是影视行业都比较喜欢用GGX。
GTR
Burley通过对Berry(与GGX公式类似,分母上的指数为1)和GGX公式的观察,提出了广义的Trowbridge-Reitz(Generalized-Trowbridge-Reitz,GTR)法线分布函数:
DGTR(h,α)=c⋅χ+(n,h)((n⋅h)2(α2−1)+1)γD_{GTR}({\bf{h}}, \alpha) = \frac{c\cdot\chi^+({\bf{n}},{\bf{h}})}{(({\bf{n}}\cdot{\bf{h}})^2(\alpha^2-1)+1)^\gamma} DGTR(h,α)=((n⋅h)2(α2−1)+1)γc⋅χ+(n,h)
其中,ccc表示缩放系数,是一个常数;γ\gammaγ用于控制尾部的形状,当γ=1\gamma=1γ=1的时候,DGTRD_{GTR}DGTR就是Berry公式,当γ=2\gamma=2γ=2的时候,DGTRD_{GTR}DGTR就是DGGXD_{GGX}DGGX。
γ\gammaγ的取值对DGTRD_{GTR}DGTR的影响如下图所示。
以下是γ=1\gamma=1γ=1和γ=2\gamma=2γ=2时的shader实现:
float DistributionGTR1(float NdotH, float r)
{if (r >= 1) return 1/PI;float a2 = r*r;float t = 1 + (a2-1)*NdotH*NdotH;return (a2-1) / (PI*log(a2)*t);
}float DistributionGTR2(float NdotH, float r)
{float a2 = r*r;float t = 1 + (a2-1)*NdotH*NdotH;return a2 / (PI * t * t);
}
效果对比
可以看出,BlinnPhong和Beckmann的差异不大。而GGX有着更平滑的边缘和更小的峰值。除此之外,GGX运算压力更小,因为它没有指数操作。
遮挡项 G
和法向分布函数DDD一样,遮挡项GGG也是入射角、出射角和表面粗糙度的函数。
有些文章会把遮挡项G和BRDF的分母(n⋅l)(n⋅v)({\bf{n}}\cdot{\bf{l}})({\bf{n}}\cdot{\bf{v}})(n⋅l)(n⋅v)放在一起组成一项约分掉,这也是一种优化思路,因为G通常包含这两个cosine因子。这里我们约定本文的遮挡项GGG是不约分(n⋅l)(n⋅v)({\bf{n}}\cdot{\bf{l}})({\bf{n}}\cdot{\bf{v}})(n⋅l)(n⋅v)的GGG。
Implicit
来源[7],有些BRDF公式会忽略遮挡项G,将其跟分母上的(n⋅l)(n⋅v)({\bf{n}}\cdot{\bf{l}})({\bf{n}}\cdot{\bf{v}})(n⋅l)(n⋅v)一起忽略掉,这就有了第一个隐式GGG:
GImplicit(l,v,h)=(n⋅l)(n⋅v)G_{Implicit}({\bf{l}},{\bf{v}},{\bf{h}})=({\bf{n}}\cdot{\bf{l}})({\bf{n}}\cdot{\bf{v}}) GImplicit(l,v,h)=(n⋅l)(n⋅v)
它的形态大概是,当且仅当视角和光源都垂直于物体表面的时候,GImplicit=1G_{Implicit}=1GImplicit=1,光源、视角与物体表面法线的夹角越大,GImplicitG_{Implicit}GImplicit越小,直到衰减为0,这也是很符合常识的。
shader实现:
float GeometryImplicit(float NdotV, float NdotL) {return NdotL * NdotV;
}
但是隐式遮挡项GImplicitG_{Implicit}GImplicit最大的问题在于,它随着视角的衰减速度太快,这会使得高光区域太窄。为了解决这个问题,我们继续看显式的遮挡项GGG。
Cook-Torrance
来源[9],GCook−TorranceG_{Cook-Torrance}GCook−Torrance解决了GImplicitG_{Implicit}GImplicit衰减速度太快的问题:
GCook−Torrance(l,v,h)=min(1,2(n⋅h)(n⋅v)v⋅h,2(n⋅h)(n⋅l)v⋅h)G_{Cook-Torrance}({\bf{l}},{\bf{v}},{\bf{h}})=\min{\left(1, \frac{2({\bf{n}}\cdot{\bf{h}})({\bf{n}}\cdot{\bf{v}})}{{\bf{v}}\cdot{\bf{h}}}, \frac{2({\bf{n}}\cdot{\bf{h}})({\bf{n}}\cdot{\bf{l}})}{{\bf{v}}\cdot{\bf{h}}}\right)} GCook−Torrance(l,v,h)=min(1,v⋅h2(n⋅h)(n⋅v),v⋅h2(n⋅h)(n⋅l))
shader实现:
float GeometryCookTorrance(float NdotV, float NdotL, float VdotH, float NdotH) {float ct1 = 2 * NdotH * NdotV / VdotH;float ct2 = 2 * NdotH * NdotL / VdotH;return min(1, min(ct1, ct2));
}
Kelemen
来源[10],也是解决GImplicitG_{Implicit}GImplicit衰减速度太快的问题,同时GKelemenG_{Kelemen}GKelemen比GCook−TorranceG_{Cook-Torrance}GCook−Torrance的效率更高:
GKelemen(l,v,h)=(n⋅l)(n⋅v)(v⋅h)2G_{Kelemen}({\bf{l}},{\bf{v}},{\bf{h}})=\frac{({\bf{n}}\cdot{\bf{l}})({\bf{n}}\cdot{\bf{v}})}{({\bf{v}}\cdot{\bf{h}})^2} GKelemen(l,v,h)=(v⋅h)2(n⋅l)(n⋅v)
shader实现:
float GeometryKelemen(float NdotV, float NdotL, float VdotH) {return NdotV * NdotL / (VdotH * VdotH);
}
Neumann
来源[8],GNeumannG_{Neumann}GNeumann用另一种方式解决了GImplicitG_{Implicit}GImplicit衰减速度太快的问题:
GNeumann(l,v,h)=(n⋅l)(n⋅v)max(n⋅l,n⋅v)G_{Neumann}({\bf{l}},{\bf{v}},{\bf{h}})=\frac{({\bf{n}}\cdot{\bf{l}})({\bf{n}}\cdot{\bf{v}})}{\max{({\bf{n}}\cdot{\bf{l}}, {\bf{n}}\cdot{\bf{v}}})} GNeumann(l,v,h)=max(n⋅l,n⋅v)(n⋅l)(n⋅v)
shader实现:
float GeometryNeumann(float NdotV, float NdotL) {return (NdotL * NdotV) / max(NdotL, NdotV);
}
但是,以上三个解决方案也不够完美。前面提到过,遮挡项G应该是入射角、出射角和表面粗糙度的函数,而以上四个G,包括隐式遮挡项都与粗糙度无关。
Smith
Smith家族[13]都是采用了前面介绍的G1G_1G1相乘的形式:
G2(l,v,h)=G1(l)G1(v)G_2({\bf{l}},{\bf{v}},{\bf{h}})=G_1({\bf{l}})G_1({\bf{v}}) G2(l,v,h)=G1(l)G1(v)
他们之间的区别就是G1G_1G1的选取不同。
Beckmann
Beckmann的GGG是跟DDD一起提出的,前面介绍过GGG是可以从DDD推导出来的,因此Beckmann的Λ\LambdaΛ为:
c=n⋅vα1−(n⋅v)2Λ(v)=erf(c)−12+12cπexp(−c2)\begin{aligned} c & = \frac{{\bf{n}}\cdot{\bf{v}}}{\alpha\sqrt{1-({\bf{n}}\cdot{\bf{v}})^2}} \\ \Lambda({\bf{v}}) & = \frac{\text{erf}(c)-1}{2}+\frac{1}{2c\sqrt{\pi}}\exp(-c^2) \end{aligned} cΛ(v)=α1−(n⋅v)2n⋅v=2erf(c)−1+2cπ1exp(−c2)
但是由于有erf\text{erf}erf函数的存在,计算起来过于复杂,因此通常用如下的近似形式:
Λ(v)≈{1−1.259x+0.396c23.535c+2.181c2,if c<1.60,if c≥1.6\Lambda({\bf{v}}) \approx \begin{cases} \frac{1-1.259x+0.396c^2}{3.535c+2.181c^2}, & \text{if }c<1.6 \\ 0, & \text{if }c\geq1.6 \end{cases} Λ(v)≈{3.535c+2.181c21−1.259x+0.396c2,0,if c<1.6if c≥1.6
因此,Beckmann的G1G_1G1为
GBeckmann(v)≈{3.535c+2.181c21+2.276c+2.577c2,if c<1.61,if c≥1.6G_{Beckmann}({\bf{v}}) \approx \begin{cases} \frac{3.535c+2.181c^2}{1+2.276c+2.577c^2}, & \text{if }c<1.6 \\ 1, & \text{if }c\geq1.6 \end{cases} GBeckmann(v)≈{1+2.276c+2.577c23.535c+2.181c2,1,if c<1.6if c≥1.6
shader实现:
float GeometryBeckmann(float NdotV, float r) {float c = NdotV / (r * sqrt(1 - NdotV * NdotV));float c2 = c * c;if (c < 1.6)return (3.535 * c + 2.181 * c2) / (1 + 2.276 * c + 2.577 * c2);elsereturn 1.0;
}
float GeometrySmithBeckmann(float NdotV, float NdotL, float r) {float ggx2 = GeometryBeckmann(NdotV, r);float ggx1 = GeometryBeckmann(NdotL, r);return ggx1 * ggx2;
}
GGX
GGX[3]跟Beckmann类似,都是从法向分布函数推导出来的:
c=n⋅vα1−(n⋅v)2Λ(v)=−1+1+1c22\begin{aligned} c & = \frac{{\bf{n}}\cdot{\bf{v}}}{\alpha\sqrt{1-({\bf{n}}\cdot{\bf{v}})^2}} \\ \Lambda({\bf{v}}) & = \frac{-1+\sqrt{1+\frac{1}{c^2}}}{2} \end{aligned} cΛ(v)=α1−(n⋅v)2n⋅v=2−1+1+c21
对应的G1G_1G1定义为
GGGX(v)=2(n⋅v)(n⋅v)+α2+(1−α2)(n⋅v)2G_{GGX}({\bf{v}}) = \frac{2({\bf{n}}\cdot{\bf{v}})}{({\bf{n}}\cdot{\bf{v}})+\sqrt{\alpha^2+(1-\alpha^2)({\bf{n}}\cdot{\bf{v}})^2}} GGGX(v)=(n⋅v)+α2+(1−α2)(n⋅v)22(n⋅v)
shader实现:
float GeometryGGX(float NdotV, float r) {float r2 = r * r;return (2 * NdotV) / (NdotV + sqrt(r2 + (1 - r2) * NdotV * NdotV));
}
float GeometrySmithGGX(float NdotV, float NdotL, float r) {float ggx2 = GeometryGGX(NdotV, r);float ggx1 = GeometryGGX(NdotL, r);return ggx1 * ggx2;
}
GGX Joint
前面提到的GGX用的是G2=G1∗G1G_2=G_1*G_1G2=G1∗G1的separable G,如果用height-correlated G,那么G2G_2G2变为:
G2−GGXJoint(l,v,m)=11+Λ(l)+Λ(v)=2(n⋅v)(n⋅l)(n⋅l)⋅α2+(1−α2)(n⋅v)2+(n⋅v)⋅α2+(1−α2)(n⋅l)2\begin{aligned} G_{2-GGXJoint}({\bf{l}},{\bf{v}},{\bf{m}}) & =\frac{1}{1+\Lambda({\bf{l}})+\Lambda({\bf{v}})}\\ & =\frac{2({\bf{n}}\cdot{\bf{v}})({\bf{n}}\cdot{\bf{l}})}{({\bf{n}}\cdot{\bf{l}})\cdot\sqrt{\alpha^2+(1-\alpha^2)({\bf{n}}\cdot{\bf{v}})^2} + ({\bf{n}}\cdot{\bf{v}})\cdot\sqrt{\alpha^2+(1-\alpha^2)({\bf{n}}\cdot{\bf{l}})^2}} \end{aligned} G2−GGXJoint(l,v,m)=1+Λ(l)+Λ(v)1=(n⋅l)⋅α2+(1−α2)(n⋅v)2+(n⋅v)⋅α2+(1−α2)(n⋅l)22(n⋅v)(n⋅l)
shader实现:
float GeometrySmithGGXJoint(float NdotV, float NdotL, float r) {float r2 = r * r;float Vis_SmithV = NdotL * sqrt(NdotV * (NdotV - NdotV * r2) + r2);float Vis_SmithL = NdotV * sqrt(NdotL * (NdotL - NdotL * r2) + r2);return 2 * NdotV * NdotL / (Vis_SmithV + Vis_SmithL);
}
为了提高计算效率,UE4对GGX Joint方法做了一个近似,公式为:
G2−GGXJoint(l,v,m)=11+Λ(l)+Λ(v)≈2(n⋅v)(n⋅l)(n⋅l)⋅(α+(1−α)(n⋅v))+(n⋅v)⋅(α+(1−α)(n⋅l))\begin{aligned} G_{2-GGXJoint}({\bf{l}},{\bf{v}},{\bf{m}}) & =\frac{1}{1+\Lambda({\bf{l}})+\Lambda({\bf{v}})}\\ & \approx\frac{2({\bf{n}}\cdot{\bf{v}})({\bf{n}}\cdot{\bf{l}})}{({\bf{n}}\cdot{\bf{l}})\cdot(\alpha+(1-\alpha)({\bf{n}}\cdot{\bf{v}})) + ({\bf{n}}\cdot{\bf{v}})\cdot(\alpha+(1-\alpha)({\bf{n}}\cdot{\bf{l}}))} \end{aligned} G2−GGXJoint(l,v,m)=1+Λ(l)+Λ(v)1≈(n⋅l)⋅(α+(1−α)(n⋅v))+(n⋅v)⋅(α+(1−α)(n⋅l))2(n⋅v)(n⋅l)
shader实现:
float GeometryGGXJointApprox(float NdotV, float NdotL, float r) {return (NdotV) / (NdotL * (r + (1 - r) * NdotV));
}
float GeometrySmithGGXJointApprox(float NdotV, float NdotL, float r) {float Vis_SmithV = NdotL * ( NdotV * ( 1 - r ) + r );float Vis_SmithL = NdotV * ( NdotL * ( 1 - r ) + r );return 2 * NdotV * NdotL / ( Vis_SmithV + Vis_SmithL );
}
Schlick-Beckmann
Schlick[11]的G1G_1G1定义为
k=α2πGSchlick(v)=n⋅v(n⋅v)(1−k)+kk=\alpha\sqrt{\frac{2}{\pi}} \\ G_{Schlick}({\bf{v}})=\frac{{\bf{n}}\cdot{\bf{v}}}{({\bf{n}}\cdot{\bf{v}})(1-k)+k} k=απ2GSchlick(v)=(n⋅v)(1−k)+kn⋅v
shader实现:
float GeometrySchlickBeckmann(float NdotV, float r) {float k = (r)*sqrt(2.0 / PI);float nom = NdotV;float denom = NdotV * (1.0 - k) + k;return nom / denom;
}
float GeometrySmithSchlickBeckmann(float NdotV, float NdotL, float r) {float ggx2 = GeometrySchlickBeckmann(NdotV, r);float ggx1 = GeometrySchlickBeckmann(NdotL, r);return ggx1 * ggx2;
}
Schlick-GGX
Schlick-GGX[12]曾经是UE4所采用的的一个模型,跟Schlick有些类似,G1G_1G1定义为
k=α2GSchlick(v)=n⋅v(n⋅v)(1−k)+kk=\frac{\alpha}{2} \\ G_{Schlick}({\bf{v}})=\frac{{\bf{n}}\cdot{\bf{v}}}{({\bf{n}}\cdot{\bf{v}})(1-k)+k} k=2αGSchlick(v)=(n⋅v)(1−k)+kn⋅v
shader实现:
float GeometrySchlickGGX(float NdotV, float r) {float k = r * 0.5;float nom = NdotV;float denom = NdotV * (1.0 - k) + k;return nom / denom;
}
float GeometrySmithSchlickGGX(float NdotV, float NdotL, float r) {float ggx2 = GeometrySchlickGGX(NdotV, r);float ggx1 = GeometrySchlickGGX(NdotL, r);return ggx1 * ggx2;
}
这里面还有一个细节,那就是迪士尼后来提出了对粗糙粗roughness
做一个remapping,使得它更接近于真实:
α′=(roughness+12)2\alpha' = (\frac{roughness + 1}{2})^2 \\ α′=(2roughness+1)2
其他的部分不变。这样shader实现为:
float GeometrySmithSchlickGGX(float NdotV, float NdotL, float roughness) {float r = (roughness + 1.0) * 0.5; // remapping roughnessr = r * rfloat ggx2 = GeometrySchlickGGX(NdotV, r);float ggx1 = GeometrySchlickGGX(NdotL, r);return ggx1 * ggx2;
}
注意,此时GeometrySmithSchlickGGX
的输入参数不是r
,而改为了roughness
。
优化
考虑到几乎所有GGG都带有(n⋅v)(n⋅l)({\bf{n}}\cdot{\bf{v}})({\bf{n}}\cdot{\bf{l}})(n⋅v)(n⋅l)项,可以跟fr(l,v)f_r({\bf{l}},{\bf{v}})fr(l,v)的分母约分,因此在实现时,可以考虑定义
G′=G(n⋅v)(n⋅l)G'=\frac{G}{({\bf{n}}\cdot{\bf{v}})({\bf{n}}\cdot{\bf{l}})} G′=(n⋅v)(n⋅l)G
节省一部分计算。
这样做不只是出于性能的考虑,也是出于精度的考虑。如果n⋅v{\bf{n}}\cdot{\bf{v}}n⋅v和n⋅l{\bf{n}}\cdot{\bf{l}}n⋅l的乘积接近于0,那么specular项的分母会非常小,严重影响其精度,极端的情况下会在过渡区域产生一道割裂的分界线。下图展示了(n⋅v)∗(n⋅l)∗10({\bf{n}}\cdot{\bf{v}})*({\bf{n}}\cdot{\bf{l}})*10(n⋅v)∗(n⋅l)∗10(左)、未优化时(中)、优化后(右)的效果,可以看出左侧两张图的分界线非常吻合。优化后则没有颜色割裂的问题。
效果对比
取roughness = 0.9
,计算球体的遮挡项效果为:
最后一排是常用的几种方法,差异并不大,边缘的过渡也比较好。
菲涅尔项 F
菲涅尔项描述的是物体表面的反射、折射现象。一般我们会采用常量F0F_0F0来计算菲涅尔项F(v,h,F0)F({\bf{v}},{\bf{h}},F_0)F(v,h,F0)。
要说明白菲涅尔项,得从光学在介质表面的折射反射现象说起。我们知道光线会在介质表面产生不连续性,具体表现为一部分光线反射——遵循光线反射定律,入射角等于反射角;另一部分光线会折射进入介质——遵循光线折射定律,折射角取决于入射角的大小以及构成交界面的两种材质,即斯涅耳定律(Snell’s law):
n1sin(θi)=n2sin(θt)n_1\sin(\theta_i)=n_2\sin(\theta_t) n1sin(θi)=n2sin(θt)
斯涅耳定律描述的仅仅是光线的角度,但是图形学研究的其实是光线的radiance/irradiance,所以我们要更进一步。定义Fresnel reflectance RFR_FRF为反射光线的radiance占入射光线radiance的比例,RFR_FRF是入射角θi\theta_iθi的函数。那么对于入射光线LiL_iLi,在角度θi\theta_iθi时反射光线的radiance为RF(θi)LiR_F(\theta_i)L_iRF(θi)Li。再考虑折射部分,根据能量守恒,没有反射的能量都会被折射(不考虑被吸收的能量),因此折射的flux占入射flux的比例是1−RF1-R_F1−RF。这里需要强调的是,radiance定义的是“irradiance每立体角”,它的大小跟角度有关系,因此折射光线的radiance LtL_tLt不能简单用1−RF1-R_F1−RF乘上LiL_iLi,而要转换角度:
Lt=(1−RF(θi))sin2θisin2θtLiL_t = (1-R_F(\theta_i))\frac{\sin^2\theta_i}{\sin^2\theta_t}L_i Lt=(1−RF(θi))sin2θtsin2θiLi
将斯涅耳定律带入上式,得到:
Lt=(1−RF(θi))n22n12LiL_t = (1-R_F(\theta_i))\frac{n_2^2}{n_1^2}L_i Lt=(1−RF(θi))n12n22Li
介绍了这么多RFR_FRF的相关知识,其实关键点还是前面说的,RFR_FRF是入射角θi\theta_iθi的函数。我们再回头考虑这个RFR_FRF与入射角θi\theta_iθi的关系。当θi=90°\theta_i=90\degreeθi=90°的时候,即RF(90°)R_F(90\degree)RF(90°),此时入射光平行于平面,垂直于法向,不存在折射光线,RF(90°)=1R_F(90\degree)=1RF(90°)=1;当θi=0°\theta_i=0\degreeθi=0°的时候,即RF(0°)R_F(0\degree)RF(0°),此时反射光线占比最低,根据不同的材质这个RF(0°)R_F(0\degree)RF(0°)有不同的值,Real-time Rendering[14]给出了常见的材质的RFR_FRF与θi\theta_iθi的关系曲线:
为了近似这个曲线,采取的策略是利用RF(0°)R_F(0\degree)RF(0°),也就是前面说的F0F_0F0:
RF(θi)≈RF(0°)+(1−RF(0°))(1−cosθi)5R_F(\theta_i)\approx R_F(0\degree) + (1-R_F(0\degree))(1-\cos\theta_i)^5 RF(θi)≈RF(0°)+(1−RF(0°))(1−cosθi)5
这里有一个默认的假设是,RF(90°)=1R_F(90\degree)=1RF(90°)=1,如果,RF(90°)R_F(90\degree)RF(90°)未知,RF(θi)R_F(\theta_i)RF(θi)应该写为:
RF(θi)≈RF(0°)+(RF(90°)−RF(0°))(1−cosθi)5R_F(\theta_i)\approx R_F(0\degree) + (R_F(90\degree)-R_F(0\degree))(1-\cos\theta_i)^5 RF(θi)≈RF(0°)+(RF(90°)−RF(0°))(1−cosθi)5
这个RF(90°)R_F(90\degree)RF(90°)也就是F90F_{90}F90。
最后,我们看一下F0F_0F0怎么计算。对于dielectrics来说,F0F_0F0的值取决于折射率,公式为:
F0=0.16⋅reflectance2F_0=0.16\cdot reflectance^2 F0=0.16⋅reflectance2
其中,reflectancereflectancereflectance由物体表面的材质定义。
对于conductors,F0F_0F0通过金属度metallic和basecolor
来计算:
F0=baseColor⋅metallicF_0=baseColor\cdot metallic F0=baseColor⋅metallic
综合dielectrics和dielectric,得到:
vec3 F0 = 0.16 * reflectance * reflectance * (1.0 - metallic) + baseColor.xyz * metallic;
说明白了F0F_0F0,我们接下来看看菲涅尔函数FFF有哪些形式。
简单形式
最简单的情况,直接令菲涅尔函数等于F0F_0F0:
FNone(v,h)=F0F_{None}({\bf{v}},{\bf{h}})=F_0 FNone(v,h)=F0
shader实现:
vec3 Fresnel(vec3 F0) {return F0;
}
Schlick
来源[11],公式:
FSchlick(v,h)=F0+(1−F0)(1−(v⋅h))5F_{Schlick}({\bf{v}},{\bf{h}})=F_0+(1-F_0)(1-({\bf{v}}\cdot{\bf{h}}))^5 FSchlick(v,h)=F0+(1−F0)(1−(v⋅h))5
也就是我们前面说到的对RFR_FRF的拟合。shader实现:
vec3 FresnelSchlick(float VdotH, vec3 F0) {return F0 + (1.0 - F0) * pow(1.0 - VdotH, 5.0);
}
如果引入F90F_{90}F90,则变成:
FSchlick(v,h)=F0+(F90−F0)(1−(v⋅h))5F_{Schlick}({\bf{v}},{\bf{h}})=F_0+(F_{90}-F_0)(1-({\bf{v}}\cdot{\bf{h}}))^5 FSchlick(v,h)=F0+(F90−F0)(1−(v⋅h))5
shader实现:
vec3 FresnelSchlick(float VdotH, vec3 F0, vec F90) {return F0 + (F90 - F0) * pow(1.0 - VdotH, 5.0);
}
对specular来说,F90F_{90}F90可以从F0F_0F0计算得来[1]:
float F90 = saturate(dot(F0, vec3(50.0 * 0.33)));
Cook-Torrance
来源[9],公式:
η=1+F01−F0c=v⋅hg=η2+c2−1FCook−Torrance(v,h)=12(g−cg+c)2(1+((g+c)c−1(g−c)c+1)2)\begin{aligned} \eta & =\frac{1+\sqrt{F_0}}{1-\sqrt{F_0}} \\ c & = {\bf{v}}\cdot{\bf{h}} \\ g & = \sqrt{\eta^2+c^2-1} \\ F_{Cook-Torrance}({\bf{v}},{\bf{h}}) & =\frac{1}{2}\left(\frac{g-c}{g+c}\right)^2\left(1+\left(\frac{(g+c)c-1}{(g-c)c+1}\right)^2\right) \end{aligned} ηcgFCook−Torrance(v,h)=1−F01+F0=v⋅h=η2+c2−1=21(g+cg−c)2(1+((g−c)c+1(g+c)c−1)2)
shader实现:
float FresnelCookTorrance(float VdotH, float F0) {float sqrtF = sqrt(F0);float Eta = (1.0 + sqrtF) / (1.0 - sqrtF);float g = sqrt(Eta * Eta + VdotH * VdotH - 1.0);return 0.5 * pow((g - VdotH) / (g + VdotH), 2) *(1 + pow(((g + VdotH) * VdotH - 1.0) / ((g - VdotH) * VdotH + 1.0), 2));
}
Diffuse BRDF
相比于繁琐的specular部分,diffuse部分就简单的多。diffuse部分由baseColor和diffuse系数相乘得到,即:
Ld(v)=cdiff⋅fdL_d({\bf{v}})={\bf{c}}_{diff}\cdot f_d Ld(v)=cdiff⋅fd
shader实现:
vec3 colorDiffuse = baseColor * DiffuseBRDF(NdotV, NdotL, LdotH, roughness);
接下来看一下fdf_dfd的可能取值。
Lambert
Lambert模型认为既然diffuse是漫反射,不如简单地认为各个方向都是一样的值,即出射光线的radiance与入射光线的角度无关。
fd=1πf_d = \frac{1}{\pi} fd=π1
这个实现相当于,采用BlinnPhong的法向分布DBlinn(h,α)=1πD_{Blinn}({\bf{h}}, \alpha) = \frac{1}{\pi}DBlinn(h,α)=π1,同时令遮挡项为隐式形式,并且菲涅尔项为1。虽然简单,但是已经足够近似现实了,效果还不错。
shader实现:
float DiffuseLambert() {return 1.0 / PI;
}
虽然Lambert模型已经足够接近真实情况,但是它还是不够理想。我们前面提到过,diffuse分量本质上是光线折射进入物体表面,经过多次反射再折射出来的现象,也就是它不是物理上真实存在的一个光学现象。而在讨论specular菲涅尔项的时候又提到过,反射部分会随着入射光线的角度变化,那么折射部分相应的也会随着入射角度变化,既然如此,来自于折射部分的diffuse分量肯定也是会随着入射光线的角度而改变的!也就是说,fdf_dfd是入射角l{\bf{l}}l的函数:fd(l)f_d({\bf{l}})fd(l)。
同时,fdf_dfd也应该是出射角v{\bf{v}}v的函数[14]:fd(l,v)f_d({\bf{l}},{\bf{v}})fd(l,v)。因为菲涅尔项考虑的是镜面反射,入射角等于出射角,而diffuse项的入射角不一定等于出射角,因此两个角度都会影响fdf_dfd。
再者,前面影响specular分量的参数当中,roughnessroughnessroughness也会影响fdf_dfd。根据常识,不同粗糙程度的物体的diffuse是有明显的不同的。即fd(l,v,roughness)f_d({\bf{l}}, {\bf{v}}, roughness)fd(l,v,roughness)。
基于这一点洞察,又有一些新的diffuse模型被提出,希望解决Lambert模型的不足。。
Oren–Nayar
Oren-Nayar模型是对Lambert模型的推广。[18]指出,Lambert模型对于光滑物体或许还成立,但是对于粗糙物体是不正确的。粗糙的物体在光照下会显得很平坦,而Lambert模型没有表现出这种平坦。为了达到这个效果,Oren-Nayar加强了掠射逆反射(入射角和出射角在几乎同一个方向,并且垂直于法向的情形)的强度。
Oren-Nayar公式如下。
fd=1π⋅(A+B⋅max(0,cosϕ)⋅sinα⋅tanβ)A=1.0−0.5αα+0.33B=0.45αα+0.09α=max(l⋅n,v⋅n)β=min(l⋅n,v⋅n)\begin{aligned} f_d & = \frac{1}{\pi}\cdot(A+B\cdot\max{(0, \cos{\phi})}\cdot\sin\alpha\cdot\tan\beta) \\ A & = 1.0-0.5\frac{\alpha}{\alpha+0.33} \\ B & = 0.45\frac{\alpha}{\alpha+0.09} \\ \alpha & = \max{({\bf{l}}\cdot{\bf{n}}, {\bf{v}}\cdot{\bf{n}})} \\ \beta & = \min{({\bf{l}}\cdot{\bf{n}}, {\bf{v}}\cdot{\bf{n}})} \end{aligned} fdABαβ=π1⋅(A+B⋅max(0,cosϕ)⋅sinα⋅tanβ)=1.0−0.5α+0.33α=0.45α+0.09α=max(l⋅n,v⋅n)=min(l⋅n,v⋅n)
其中,ϕ\phiϕ表示ln{\bf{l}}{\bf{n}}ln平面和vn{\bf{v}}{\bf{n}}vn的夹角。
可以看出,当roughness−0roughness-0roughness−0的时候,A=1,B=0A=1, B=0A=1,B=0,此时Oren-Nayar模型退化为Lambert模型。
下图[18]展示了真实照片、Lambert模型与Oren-Nayar的对比。
Hanrahan-Krueger
Hanrahan-Krueger模型[19]其实是源自次表面散射理论,是用于表现次表面散射现象的一个模型。它跟Oren-Nayar模型一样,对掠射角进行了补偿。但是它的补偿过于平坦,没有给出足够强的峰值,也不太完美。
Hanrahan-Krueger模型和Oren-Nayar模型都不太常用,因此不再赘述。
Burley
Oren–Nayar模型虽然提高了粗糙物体的真实性,但是它对掠射逆反射现象的修正还是不够真实。为了研究真实材料的物理特性,我们需要一个材质数据库。
MERL BRDF Database就是这样一个数据库。它是由MERL(Mitsubishi Electric Research Laboratories)实验室建立了的,测量并记录了不同角度的光源、观测视角情况下的BRDF数值,考虑到各向异性,每个材质都采样了90(光源)*90(视角)*180(各向异性)三个维度的数据。如果只考虑各向同性材质,可以将BRDF数据压缩到一张图片里。
如上图所示,横轴θh\theta_hθh表示half vector h{\bf{h}}h与法向量n{\bf{n}}n之间的夹角。纵轴表示入射角与h{\bf{h}}h的夹角。
Disney通过分析MERL BRDF Database,提出了两个Lambert模型与事实不符的地方:
- diffuse也会有类似于specular的光斑;
- 部分材质的diffuse会在掠射角有明显的光环,这个现象即掠射逆反射(grazing retroreflection);
为了解决这些问题,Disney提出了一个diffuse BRDF公式[15]:
fd(l,v)=1πFSchlick(n,l,1,f90)FSchlick(n,v,1,f90)FSchlick(n,l,f0,f90)=F0+(F90−F0)(1−(n⋅l))5f90=0.5+2⋅roughness⋅cos2(θd)\begin{aligned} f_d({\bf{l}},{\bf{v}}) & = \frac{1}{\pi}F_{Schlick}({\bf{n}},{\bf{l}},1,f_{90})F_{Schlick}({\bf{n}},{\bf{v}},1,f_{90}) \\ F_{Schlick}({\bf{n}},{\bf{l}},f_0,f_{90}) & = F_0+(F_{90}-F_0)(1-({\bf{n}}\cdot{\bf{l}}))^5 \\ f_{90} & = 0.5 + 2\cdot roughness\cdot\cos^2(\theta_d) \end{aligned} fd(l,v)FSchlick(n,l,f0,f90)f90=π1FSchlick(n,l,1,f90)FSchlick(n,v,1,f90)=F0+(F90−F0)(1−(n⋅l))5=0.5+2⋅roughness⋅cos2(θd)
其中θd\theta_dθd是光线LLL和half vector hhh的夹角。这个公式考虑到了入射角和出射角以及粗糙度,并且用类似菲涅尔项的公式(cosine项的五次方)来拟合衰减情况。
shader实现:
float FresnelSchlick(float VdotH, float F0, float F90) {return F0 + (F90 - F0) * pow(1.0 - VdotH, 5.0);
}
float DiffuseBurley(float NdotV, float NdotL, float LdotH, float roughness) {float f90 = 0.5 + 2.0 * roughness * LdotH * LdotH;float lightScatter = FresnelSchlick(NdotL, 1.0, f90);float viewScatter = FresnelSchlick(NdotV, 1.0, f90);return lightScatter * viewScatter * (1.0 / PI);
}
总结
BRDF作为渲染里边最基础的知识点,发展的已经相对成熟,虽然偶尔也会有一些改进,但是基本上都是在效率与性能之间做权衡。对于基本BRDF公式的选择,UE4和Disney有着各自不同的逻辑:
Diffuse BRDFDistributionVisibilityFresnelUE4LambertGGXGGXJoint(Approx)SchlickDisneyBurleyGGXGGXSchlick\begin{array}{c|ccccc} & \text{Diffuse BRDF} & \text{Distribution} & \text{Visibility} & \text{Fresnel} \\ \hline \text{UE4} & Lambert & \text{GGX} & GGX Joint(Approx) & \text{Schlick} \\ \text{Disney} & Burley & \text{GGX} & GGX & \text{Schlick} \end{array} UE4DisneyDiffuse BRDFLambertBurleyDistributionGGXGGXVisibilityGGXJoint(Approx)GGXFresnelSchlickSchlick
斜体表示二者不同的部分。可以看出,UE4选择的都是高效的模型,而Disney选择的都是复杂而准确的模型。
个人理解这些差异都是源于UE4和disney应用场景的不同,UE4希望每个模型尽可能高效,因此会拆分开来,针对性优化,比如它单独设计了针对眼睛的Eye模型,专门渲染毛发的Hair模型,专门渲染皮肤的subsurface模型等等。而Disney的诉求在于模型的表达力要足够强,效率反而不那么重要。
未涉及话题…
本文主要集中在BRDF项的各种实现,顺带介绍了BRDF和微表面理论。还有一些与之相关或更深入,但是没有涉及到的方向,例如
- 辐射度量学基础;
- BSDF,BTDF等BRDF的进阶模型;
- 各向异性BRDF,subsurface、clearCoat等模型;
- 环境光、全局光照等;
篇幅问题,这些方向也无法展开。行文至此,强推图形学届的武林秘籍的目录——Real-time Rendering,此书目前已经出到第四版了,文末也给出了电子书链接[14]。本文涉及的话题书中都有比较深入、全面的介绍。即使RTR不能满足你,它还提供了多达1000+篇的参考文献供学习,毕竟“目录”,名副其实。
参考资料
- Filament文档,Filament是一个Google写的用在Android上的PBR渲染器,它的文档非常完善,特别每个BRDF的理论和实现。同时也可以参考它的源码,对照学习。
- Specular BRDF Reference:这个博客列出了几大主流specular BRDF的公式,可以作为参考。
- Walter et al. 2007, Microfacet models for refraction through rough surfaces
- LearningOpenGL: PBR Theory:这也是一个不错的学习PBR的教材,有一个PBR的OpenGL实现,以及简单的理论介绍。
- Beckmann 1963, The scattering of electromagnetic waves from rough surfaces
- Blinn 1977, Models of light reflection for computer synthesized pictures
- Hoffman 2013, Background: Physics and Math of Shading
- Neumann et al. 1999, Compact metallic reflectance models
- Cook and Torrance 1982, A Reflectance Model for Computer Graphics
- Kelemen 2001, A microfacet based coupled specular-matte brdf model with importance sampling
- Schlick 1994, An Inexpensive BRDF Model for Physically-Based Rendering
- Karis 2013, Real Shading in Unreal Engine 4
- Smith 1967, Geometrical shadowing of a random rough surface
- Real-time Rendering, 4th edition,需要说明的一点是,此书的第四版比第三版增加了很多对BRDF公式的推导和历史介绍,更具有参考价值。
- Brent Burley. 2012. Physically Based Shading at Disney. Physically Based Shading in Film and Game Production, ACM SIGGRAPH 2012 Courses.
- Understanding the Masking-Shadowing Function in Microfacet-Based BRDFs
- SIGGRAPH 2013 Course, Background: Physics and Math of Shading
- Generalization of Lambert’s reflectance model
- Reflection from Layered Surfaces due to Subsurface Scattering
- SIGGRAPH 2013 Course, Physically Based Shading at Disney
- PBR Diffuse Lighting for GGX+Smith Microsurfaces
BRDF理论及shader实现(下)相关推荐
- BRDF理论及shader实现(上)
摘要 本文将从渲染方程谈起,介绍实时渲染中常用的几种BRDF公式,相关理论及其代码实现. 篇幅所限,整篇文章分为两部分,下篇连接: BRDF理论及shader实现(下) 理论介绍 渲染这个事情解决的是 ...
- 系统辨识理论及MATLAB仿真——学习笔记(1)
系统辨识理论及MATLAB仿真学习笔记(1) 前言 目录 第1章 绪论 1.1 建立数学模型的基本方法 1.2 系统辨识的定义 1.3 系统辨识的研究目的 1.4 数学模型的分类 1.5 几种常见的数 ...
- matlab传递函数参数辨识,5.2 传递函数的频域辨识 系统辨识理论及Matlab仿真课件.ppt...
您所在位置:网站首页 > 海量文档  > 高等教育 > 习题/试题 5.2 传递函数的频域辨识 系统辨识理论及Matl ...
- matlab做copula,Copula理论及MATLAB应用实例
Copula理论及MATLAB应用实例 %-------------------------------------------------------------------------- % Co ...
- 单相桥式半控整流matlab仿真,三相桥式半控整流电路理论及MATLAB仿真实现.doc
三相桥式半控整流电路理论及MATLAB仿真实现.doc 三相桥式半控整流电路整流电路是电力电子技术中最为重要,也是应用得最为广泛的电路,不仅应用于一般工业领域,也广泛应用于交通运输.电力系统.通信系统 ...
- 深入URP之Shader篇3: Unlit Shader分析[下]
Unlit shader 上篇中我们分析了Unlit shader的Properties在ShaderGUI中的处理,接下来看Sub Shader. SubShader unlit shader以及其 ...
- MySQL笔记-binlog理论及binlog回滚恢复数据
binlog:MySQL数据库的二进制日志,记录用户对数据库操作的SQL语句(select除外),使用mysqlbin可以查看其内容 binlog有三种: 1. STATMENT:基于SQL语句的复制 ...
- lms算法的verilog实现_最小均方算法(LMS Algorithm)理论及DSP实现
LMS算法可认为是机器学习里面最基本也比较有用的算法,神经网络中对参数的学习使用的就是LMS的思想,在通信信号处理领域LMS也非常常见,比如自适应滤波器. 本文主要对LMS(Least Mean Sq ...
- 最小均方算法(LMS Algorithm)理论及DSP实现
LMS算法可认为是机器学习里面最基本也比较有用的算法,神经网络中对参数的学习使用的就是LMS的思想,在通信信号处理领域LMS也非常常见,比如自适应滤波器. 本文主要对LMS(Least Mean Sq ...
最新文章
- linux mint 17.3 内核,LinuxMint 17.3 Cinnamon抢鲜评测
- 信息检索及信息过滤方法概述
- python模块批量安装方法_python离线批量安装依赖包
- freemaker word转html,ftl文件-word文档转化成ftl文件
- Python 根据起始时间和结束时间计算时长
- Linux下读取RFID卡号(C串口编程)
- Oracle EBS APP-FND-02901: you don‘t have privilege to any operating unit
- HDU1870 愚人节的礼物【堆栈+输入输出】
- txt文本去重复 亲测50G文本高效去重复
- 夜神模拟器+Fiddler抓包测试App
- Label Smoothing 标签平滑 (Label smooth regularization, LSR)
- Mac无缝更换电脑--数据迁移
- arduino控制小车转向_利用XECU和激光雷达快速搭建入门级的自动驾驶小车
- 基于Android的租车app
- 面向初学者的 20 大人工智能项目创意
- 记一次用python selenium 通过chrome+chromedriver 实现公司内网上网自动登入认证、上班自动登入打卡python脚本笔记
- 机械革命台式计算机,机械革命台式机怎么样
- 2020上海高校程序设计竞赛暨第18届上海大学程序设计联赛夏季赛(同步赛)整理合集
- 乐学python公众号_《乐》字意思读音、组词解释及笔画数 - 新华字典 - 911查询
- xcode写在_迷失在Xcode领域
热门文章
- 金融法重点复习(整理)
- jsp+struct标签实现分页(只有jsp代码),内用到迭代标签、if标签、bean
- 黑白打印机M268 dw安装教程
- 三行代码,轻松实现 Scrapy 对接新兴爬虫神器 Playwright!
- [转幺幺]硬件解码—NDS影音播放专家iPlayer卡评测
- excel导出access_将Access 2003报表导出到Excel电子表格中
- Access2003和Access2007数据库
- 推荐一个支持各种地理坐标系、投影坐标系的在线转换工具
- python用户手册是什么_Wind量化平台-用户手册(Python).pdf
- opencv3.4.0安装contrib3.0.0模块