软件光栅器实现(二、VS和PS的运作,法线贴图,切空间的计算)
二、软件光栅器的VS和PS的输入、输出和运作,实现法线贴图效果的版本。转载请注明出处。
这里介绍的VS和PS是实现法线映射的版本,本文仅介绍实现思路,并给出代码供参考。切空间计算、光照模型等相关公式不是本文重点,本文暂不给出,读者可以查阅其他博文或文献。
软光栅的顶点部分处理放在VS也就是顶点着色器中进行,输入顶点的数据结构:
//顶点信息 包括坐标,颜色,纹理坐标,法线等等 class VertexIn { public: //顶点位置 ZCVector pos; //顶点颜色 ZCVector color; //纹理坐标 ZCFLOAT2 tex; //法线 ZCVector normal; ZCVector tangent; //ZCVector bitangent; VertexIn() = default; VertexIn(ZCVector pos, ZCVector color, ZCFLOAT2 tex, ZCVector normal) :pos(pos), color(color), tex(tex), normal(normal) {} VertexIn(const VertexIn& rhs):pos(rhs.pos),color(rhs.color),tex(rhs.tex),normal(rhs.normal){} };
输入数据结构带有normal和tangent成员,分别表示三角形各个顶点的法线和切向量坐标,切向量是基于各点的纹理坐标由切空间公式算出来的,关于算切空间公式网上已有许多,还可以参考《3D游戏中的数学方法》一书,这里贴出实现代码:
for (int i = indexStart; i < indexCount / 3; ++i)//计算顶点的tb
{ VertexIn p1 = m_vertices[vertexStart + m_indices[3 * i]]; VertexIn p2 = m_vertices[vertexStart + m_indices[3 * i + 1]]; VertexIn p3 = m_vertices[vertexStart + m_indices[3 * i + 2]]; //通过纹理和顶点坐标计算出tangent和bitangent,继而得到tbn矩阵 ZCVector Q1 = p2.pos - p1.pos;//顶点相减w仍为1 ZCVector Q2 = p3.pos - p1.pos; float s1 = p2.tex.u - p1.tex.u; float t1 = p2.tex.v - p1.tex.v; float s2 = p3.tex.u - p1.tex.u; float t2 = p3.tex.v - p1.tex.v; float ratio = 1 / (s1*t2 - s2*t1); ZCVector T;//sdir T.x = (t2 * Q1.x - t1 * Q2.x) * ratio;//t2*Q1x-t1*Q2x T.y = (t2 * Q1.y - t1 * Q2.y) * ratio; T.z = (t2 * Q1.z - t1 * Q2.z) * ratio; ZCVector B;//tdir B.x = (s1 * Q2.x - s2 * Q1.x) * ratio; B.y = (s1 * Q2.y - s2 * Q1.y) * ratio; B.z = (s1 * Q2.z - s2 * Q1.z) * ratio; Tv[indexStart + m_indices[3 * i]] = Tv[indexStart + m_indices[3 * i]] + T;//计算每个顶点的tangent向量.加上uv镜像代码时这一段注释掉 Bv[indexStart + m_indices[3 * i]] = Bv[indexStart + m_indices[3 * i]] + B; Tv[indexStart + m_indices[3 * i + 1]] = Tv[indexStart + m_indices[3 * i + 1]] + T; Bv[indexStart + m_indices[3 * i + 1]] = Bv[indexStart + m_indices[3 * i + 1]] + B; Tv[indexStart + m_indices[3 * i + 2]] = Tv[indexStart + m_indices[3 * i + 2]] + T; Bv[indexStart + m_indices[3 * i + 2]] = Bv[indexStart + m_indices[3 * i + 2]] + B;
Tv[vertexStart + m_indices[3 * i]].Normalize();//对每个点的Tv和Bv归一化
Bv[vertexStart + m_indices[3 * i]].Normalize();
Tv[vertexStart + m_indices[3 * i + 1]].Normalize();
Bv[vertexStart + m_indices[3 * i + 1]].Normalize();
Tv[vertexStart + m_indices[3 * i + 2]].Normalize();
Bv[vertexStart + m_indices[3 * i + 2]].Normalize();
p1.tangent = Tv[vertexStart + m_indices[3 * i]];
p2.tangent = Tv[vertexStart + m_indices[3 * i + 1]];
p3.tangent = Tv[vertexStart + m_indices[3 * i + 2]];
p1.normal.Normalize();
p2.normal.Normalize();
p3.normal.Normalize();
//算三角形累加后的偏手性,存储在w分量中
ZCVector crossNT = p1.normal.Cross(p1.tangent);
p1.tangent.w = (crossNT.Dot(Bv[vertexStart + m_indices[3 * i]]) < 0.0f) ? -1.0f : 1.0f;
crossNT = p2.normal.Cross(p2.tangent);
p2.tangent.w = (crossNT.Dot(Bv[vertexStart + m_indices[3 * i + 1]]) < 0.0f) ? -1.0f : 1.0f;
crossNT = p3.normal.Cross(p3.tangent);
p3.tangent.w = (crossNT.Dot(Bv[vertexStart + m_indices[3 * i + 2]]) < 0.0f) ? -1.0f : 1.0f;
}
这段代码(进入VS处理之前)还包含有副切向量的计算,这里有两种处理的方法。①副切向量Bitangent(向量B)可以在此时,也就是输入VS之前算出来,存储到输入顶点的数据结构中,参与之后的运算;②也可以只存储法线和切向量,在之后要用到副切向量时,通过施密特正交化的方法,通过法线和切线叉乘求出来。一般来说,我们用第二种方法,因为一般一个视口内的点有许多许多,能在让一个点存储的数据量越少越好,所以可以之存储法线和切向量。此处代码把副切向量B求出是为了让读者理解向量B的意义。
VS输出顶点的数据结构:
//经过顶点着色器输出的结构 class VertexOut { public: //世界变换后的坐标 ZCVector posTrans; //投影变换后的坐标 ZCVector posH; //纹理坐标 ZCFLOAT2 tex; //法线 ZCVector normal; //颜色 ZCVector color; //1/z用于深度测试 float oneDivZ; ZCVector viewInTangent; ZCVector lightInTangent;//新定义切线和副切线成员 }
输出顶点维护有视线向量的切向量和光线向量的切向量,它们在VS里进行计算:
VertexOut BoxShader::VS(const VertexIn& vin)//参考https://github.com/zhangbaochong/Tiny3D { VertexOut out; //out.normal = vin.normal; //顶点到观察点向量 ZCVector ViewDir = (m_eyePos - vin.pos).Normalize(); m_dirLight.direction = ZCVector(-0.57735f, -0.57735f, 0.57735f, 0.f); //m_dirLight.direction = ZCVector(-0.57735f, -0.57735f, 0.57735f, 0.f); ZCVector LightDir = (-m_dirLight.direction).Normalize(); ZCVector ViewDirInModel = ViewDir*m_worldInvTranspose;//世界到模型空间 ZCVector LightDirInModel = LightDir*m_worldInvTranspose;//世界到模型空间 ZCVector vinTangent = vin.tangent - vin.normal*vin.tangent.Dot(vin.normal);//t和n正交化一下 ModifyZero(vinTangent); ZCVector vinBitangent = vin.normal.Cross(vin.tangent)*vin.tangent.w; ModifyZero(vinBitangent); ZCMatrix TBN = {//不需要正交化 vinTangent.x, vinBitangent.x, vin.normal.x, 0, vinTangent.y, vinBitangent.y, vin.normal.y, 0, vinTangent.z, vinBitangent.z, vin.normal.z, 0, 0, 0, 0, 1 }; ZCMatrix TBNINInvTranspose = MathUtil::ZCMatrixTranspose(MathUtil::ZCMatrixInverse(TBN)); ZCVector ViewDirInTan = ViewDirInModel*TBNINInvTranspose;//模型空间到切空间 ZCVector LightDirInTan = LightDirInModel*TBNINInvTranspose;//模型空间到切空间 out.viewInTangent = ViewDirInTan;//存储切空间下的视线向量值 out.lightInTangent = LightDirInTan;//存储切空间下的光线向量值 out.posH = vin.pos * m_worldViewProj; out.posTrans = vin.pos * m_world; out.normal = out.normal * m_worldInvTranspose; out.color = vin.color; out.tex = vin.tex; //if (out.lightInTangent.y < 0) // out.lightInTangent.y *= -1;//不知道什么原因有时候会变成负数 return out; }
在输入数据的阶段,定义了光线和视线等向量在世界空间下的坐标。对于视线和光线的向量,在VS阶段就把它们由世界坐标转换到了切空间中,原因在于:法线贴图上的数据,是需要逐像素处理的,所以按理来说,应该在Ps阶段中求出对应的光线和视线的切、法、副切三个分量值,得出切空间坐标,然后进行法线映射的计算。但是!PS是逐像素处理数据的,而VS是逐顶点处理数据的!一个模型渲染到屏幕上像素数肯定会远远大于顶点数,所以提前在VS阶段,也就是逐顶点处理过程中,就把模型中投在每一个点上的光线和视线的切空间分量算出来,这样就会大大节省运算成本,提高速度!到了PS中,顶点的光线和视线向量就已经在切空间中了,这样就免去了大量的计算开销。
在VS输出之后,还要进行透视矫正(各属性要乘以z的倒数)、裁剪、确定三角形的索引,然后开始扫面三角形。在扫描三角形每条线时,就是话一个个水平的连续像素点的过程,而PS阶段就是在画点时展开的。
PS输入顶点的数据结构就是VS的输出顶点结构。PS代码如下:
ZCVector BoxShader::PS(VertexOut& pin) { //纹理采样 ZCVector texColor = m_tex.Sample(pin.tex); //用采样法相贴图来代替pin.normal ZCVector normalColor = m_normalmap.Sample(pin.tex); ZCVector normalFrommap;// = { 0.f, 0.f, 0.f, 0.f }; normalFrommap.x = normalColor.x * 2 - 1; normalFrommap.y = normalColor.y * 2 - 1; normalFrommap.z = normalColor.z * 2 - 1; m_dirLight.direction = pin.lightInTangent;//光线向量已经转成切空间,仅把光线方向赋给灯光对象 ZCVector toEye = pin.viewInTangent;//观察向量,已经转成切空间和归一化 //采样高光贴图 ZCVector specColor = m_specmap.Sample(pin.tex); //衰减系数 float atte = 0.25; specColor = specColor*atte; //初始化各颜色 ZCVector ambient(0.0f, 0.0f, 0.0f, 0.0f); ZCVector diffuse(0.0f, 0.0f, 0.0f, 0.0f); //ZCVector specular(0.0f, 0.0f, 0.0f, 0.0f);//仅法线贴图,不用高光 ZCVector specular(specColor.x, specColor.y, specColor.z, specColor.w); //光源计算后得到的环境光、漫反射 、高光 ZCVector A, D, S; Lights::ComputeDirectionalLight(m_material, m_dirLight, normalFrommap, toEye, A, D, S);//法线贴图用normalFrommap ambient = ambient + A; diffuse = diffuse + D; specular = specular + S; //纹理+光照计算公式: 纹理*(环境光+漫反射光)+高光 ZCVector litColor = texColor * (ambient + diffuse) + specular + pin.color; litColor.x = (litColor.x > 1.0f) ? 1.0f : litColor.x; litColor.y = (litColor.y > 1.0f) ? 1.0f : litColor.y; litColor.z = (litColor.z > 1.0f) ? 1.0f : litColor.z; litColor.w = (litColor.w > 1.0f) ? 1.0f : litColor.w; return litColor; }
各像素的光照计算在Lights::ComputeDirectionalLight()中进行:
//计算平行光 inline void ComputeDirectionalLight( const Material& mat, //材质 const DirectionalLight& L, //平行光,方向向量值以变换为切空间 ZCVector normal, //顶点法线 ZCVector toEye, //顶点到眼睛的向量 ZCVector& ambient, //计算结果:环境光 ZCVector& diffuse, //计算结果:漫反射光 ZCVector& spec) //计算结果:高光 { // 结果初始化为0 ambient = ZCVector( 0.0f, 0.0f, 0.0f, 0.0f ); diffuse = ZCVector(0.0f, 0.0f, 0.0f, 0.0f); spec = ZCVector(0.0f, 0.0f, 0.0f, 0.0f); // 环境光直接计算 ambient = mat.ambient * L.ambient; // 计算漫反射系数 float diffuseFactor = L.direction.Dot(normal); // 顶点背向光源不再计算 if (diffuseFactor > 0.0f) { //入射光线关于法线的反射向量 ZCVector R = MathUtil::Reflect(-L.direction, normal); float specFactor = pow(max(R.Dot(toEye), 0.0f), mat.specular.w); //计算漫反射光 diffuse = mat.diffuse * L.diffuse * diffuseFactor; //计算高光 spec = mat.specular * L.specular * specFactor; } }
其中normal是从法线贴图中读取得到的,整个计算都是在切空间,也就是各个顶点的顶点空间中开展的。
PS最终会返回一个颜色值,这个颜色值,通过Windows系统维护的一个图形缓存数组进行记录,从而完成最终渲染。
m_pDevice->DrawPixel(xIndex, yIndex, m_pShader->PS(out));
//画像素 void Tiny3DDevice::DrawPixel(int x, int y, ZCVector color) { m_pFramebuffer[m_width*y + x] = MathUtil::ColorToUINT(color); }
通过在PS中对法线贴图的读取,运用到光照模型中,最终可以得到凹凸不平的渲染效果:
下一节(三、裁剪):https://www.cnblogs.com/zeppelin5/p/10042863.html
· 记一次 .NET某汗液测试机系统 崩溃分析
· 深度解析Mamba与状态空间模型:一图带你轻松入门
· 记一次 .NET某电商医药网站 CPU爆高分析
· 内存条的基本知识与选购指南
· Kafka的日志段为什么不用内存映射?
· .Net程序员机会来了,微软官方新推出一个面向Windows开发者本地运行AI模型的开源工具
· 2024个人总结
· 2024年终总结 : 迷茫, 尝试突破, 内耗, 释怀
· JSON解析的这6种方案,真香!
· 开源商业化 Sealos 如何做到月入 160万