本篇总结一下里程碑3到4阶段的材质和前向渲染的实现。本部分仍然处于早期,为了看到效果,很多地方只有思路没有实现,代码里有很多TODO。尽管很不完善,但是还是可以用来实现一些渲染技术和效果的,比如里程碑4的法线贴图和RenderTexture镜子。
在实现之前,对于材质我想可能用一个json文件描述吧,但最终我选择了直接用代码写材质。一方面定义并解析一个json格式或其他格式的材质很费时间,另外目前对于材质的需求就是简单好用,能快速实现效果,并且手写shader的方案想做的很完善也是有很多的工作要做的。在实现的过程中我感觉到,最终的方向应该还是自动生成shader代码吧,毕竟shader往往需要很多变体,并不是你写一套shader就完事的,为了处理很多不同的情况,又为了避免简单复制shader代码,在shader中定义不同的编译选项是不可避免的,这么走下去就是Unity的shader实现了,看上去是写了CG,实际背后引擎做了很多事情,最后还是会生成shader代码。而且生成代码还可以接上连节点的图形shader编辑器。好了,回到mini3d.js,看看目前的方案解决了哪些问题。
很多渲染技术都需要多个Pass,而且在多光源渲染时,多Pass渲染也是一个方案。那么每个pass就是对应一个shader program,而且同一个材质的同一个pass的不同实例,是共享一个shader program的,这避免了创建重复program以及切换program的开销。但是不同的pass可能是需要使用不同的uniform的,那么我们要将uniform放到pass级别去设置吗?这样易用性差一些。我还是选择在材质这一级设置uniform。然后在为每个pass设置uniform时先判断一下是否存在,由于shader的封装,这个操作很简单。
材质的基类Material提供了一个静态方法去创建Shader
static createShader(vs, fs, attributesMap){
let shader = new Shader();
if (!shader.create(vs, fs)) {
console.log("Failed to initialize shaders");
//Set to a default error replace shader
shader.create(vs_errorReplace, fs_errorReplace);
}
shader.setAttributesMap(attributesMap);
return shader;
}
这个方法传入vs/fs代码,以及属性映射(之前Shader的封装讲过)。如果shader创建失败,则会创建一个特殊的显示紫色的shader来提醒开发者。
首先,基类提供了一个方法addRenderPass创建pass并添加到材质中。
addRenderPass(shader, lightMode=LightMode.None){
let pass = new RenderPass(lightMode);
pass.shader = shader;
pass.index = this.renderPasses.length;
this.renderPasses.push(pass);
return pass;
}
lightMode参数用来定义该pass在渲染路径中的作用,比如是基础pass还是额外pass,亦或者是投影pass。后面前向渲染会讲到。
创建pass是在材质类的构造函数中完成,首先会使用上面的createShader代码,每个pass都要调用一次,并且对于所有实例只保存一个shader对象。然后传入shader调用addRenderPass创建pass并添加到材质。
createShader方法接收代码字符串,因此只要在创建材质时能获取到shader的字符串即可。引擎内置的材质为了方便是自己写在材质代码中的。当然用户是可以将shader单独存放到文件中,并使用引擎提供的资源管理载入shader文本。但是我觉得直接写在材质类中有利于保持完整性。
因为很多材质都会使用一些同样的uniform,比如MVP矩阵,因此我将这类常用的uniform单独拿出来,组成一组SystemUniforms。材质可以定义自己使用了哪些systemUniform。渲染框架会在渲染之前给材质设置上它使用到的system uniform,并且这些uniform的计算都是引擎提供的,这样自定义的材质可以很好的和引擎结合使用。举个例子,在matNormalMap这个材质中,我们需要这些system uniform:
//Override
get systemUniforms(){
return [SystemUniforms.MvpMatrix,
SystemUniforms.World2Object,
SystemUniforms.Object2World,
SystemUniforms.WorldCameraPos,
SystemUniforms.SceneAmbient,
SystemUniforms.LightColor, SystemUniforms.WorldLightPos];
}
相对于预定义并且引擎提供计算的System uniform,其他材质使用的Uniform被称为custom uniform。在材质的基类Material中,定义了一个接口:
//Override
//材质子类中手动设置uniform,需要重载
setCustomUniformValues(pass){
}
这个接口会在渲染pass之前被调用,因此具体的材质类只要实现这个接口,就可以设置自定义的属性了。例如matNormalMap中:
//Override
setCustomUniformValues(pass){
pass.shader.setUniformSafe('u_specular', this._specular);
pass.shader.setUniformSafe('u_gloss', this._gloss);
pass.shader.setUniformSafe('u_colorTint', this._colorTint);
pass.shader.setUniformSafe('u_texMain_ST', this._mainTexture_ST);
pass.shader.setUniformSafe('u_normalMap_ST', this._normalMap_ST);
if(this._mainTexture){
this._mainTexture.bind(0);
pass.shader.setUniformSafe('u_texMain', 0);
}
if(this._normalMap){
this._normalMap.bind(1);
pass.shader.setUniformSafe('u_normalMap', 1);
}
}
使用setUniformSafe的原因是渲染pass时并不知道pass使用了哪部分的自定义属性,因为材质可能有多个pass,而Uniform是定义在材质这一层上,作为材质的属性的。所以setUniformSafe内部会检查shader是否使用了这个属性。
renderPass(mesh, context, pass){
pass.shader.use();
this.setSysUniformValues(pass, context);
this.setCustomUniformValues(pass);
mesh.render(pass.shader);
}
很简单,设置当前pass的shader为use,设置system uniform和custom uniform,然后对于mesh调用render,传入pass的shader。这儿context的作用传入引擎计算好的system uniform。
到里程碑4,引擎提供了单色,逐顶点光照,逐像素光照,法线贴图,镜子等材质。这个阶段的材质还没有处理阴影和Lightmap。后期会继续添加各种材质,并且会增加阴影和lightmap的支持。
在延迟渲染出现之后,传统的渲染路径被称作前向渲染。简单来说,就是针对每个camera可以看到/渲染的物体逐个进行渲染。如果物体受光照,还要根据某种规则选择可以照亮它的灯光。另外,由于透明物体和不透明物体的渲染顺序不同,以及会有一些特殊的需求,产生了渲染队列的概念。物体被放到不同的队列里面去渲染。在不同的队列中,物体可能需要按材质以及深度进行排序,这是为了优化(比如降低填充以及有利于batch)或者半透明物体渲染的正确性。这些概念,几乎所有的3D引擎都有,而且从早期的固定流水线时代就已经存在。回到mini3d.js,我们探索一下这些成熟的技术,很多只是从使用方式上了解某某引擎是这么设定的,并没有源码可以参考,所以符合我们从零开始手撸的设定,当然目前不可能做的那么完善,因此留着一些TODO以后去完善,主要是优化方面的。目前实现的部分是多camera多光源多pass的不透明物体的渲染。暂时没有渲染队列,等到后面实现半透明物体渲染时再添加。暂时也没有材质排序和深度排序,仍然留着后面完善。
render(){
//TODO: 找出camera, 灯光和可渲染结点,逐camera进行forward rendering
//1. camera frustum culling
//2. 逐队列渲染
// 2-1. 不透明物体队列,按材质实例将node分组,然后排序(从前往后)
// 2-2, 透明物体队列,按z序从后往前排列
//TODO: camera需要排序,按指定顺序渲染
for(let camera of this.cameras){
camera.beforeRender();
//TODO:按优先级和范围选择灯光,灯光总数要有限制
for(let rnode of this.renderNodes){
rnode.render(this, camera, this.lights);
}
camera.afterRender();
}
}
场景管理了所有的节点,因此场景知道自己有哪些camera,有哪些灯光。可能从设计上来说,直接将render的入口放到scene并不好,至少也得有个sceneRenderer之类的东西吧。但是我不喜欢在事情没那么复杂之前把事情搞复杂。而且我对mini3d.js的定位是研究学习为主,兼顾实用,所以结构越简单越好。那么直接render也挺好。代码里面看有很多的TODO,目前我们做的就是遍历所有的camera,在渲染之前执行camera.beforeRender()这里面会更新camera的视图矩阵,设置clear color, depth,并且根据设置执行clear。然后我们对于每个camera,遍历所有的可渲染节点,目前每个camera都会渲染场景中的所有可渲染节点,后期应该会加上一个culling layer/culling mask来区分,并且会加上frustum culling以及空间划分优化。目前这些不是重点,目前的重点是执行渲染。对于每个可渲染节点,我们执行它的render方法,传入当前场景,camera和灯光。之后调用camera.afterRender(),这里面会做一些清理工作,例如如果这个camera是渲染到贴图的,需要清空FBO绑定以恢复屏幕的view port。
上面节点的render方法,其实是调用了其renderer组件的render方法。因为节点使用对象组件模式,所以render方法只是一个包装,其内部实现只是获取renderer组件,如果存在,则调用renderer.render()。目前只实现了一个MeshRenderer,未来应该会有SkinnedMeshRenderer,所以代码仍然会继续重构。我们这儿讨论的代码是基于里程碑3和4的。虽然有很多东西都是TODO状态,MeshRenderer.render()方法仍然有很多代码,下面具体说一下整个流程。
render(scene, camera, lights){
...
}
需要传入当前scene,camera和灯光。会从这些信息计算出uniform。
第一步,会根据当前材质的systemUniforms,去按需计算用到的system uniform。所有计算好的uniform值,会存放到uniformContext这个map中,这个map会在renderPass时传入。
这部分还没完善,主要参考了Unity的设计。大体上来说是选出一个最亮的平行光作为主光源。然后其他的光源作为附加光源。这里面有多种策略可选择,比如你可以在一个shader里面执行多个光源的光照计算,但由于FS中的计算不能太复杂,所以一般只在VS中这么做。你也可以使用多Pass渲染来叠加多个光源的效果。这样单个FS的计算不会很复杂,但代价是Pass次数增多了。目前采用的规则是第二个,只有第一个平行光作为主光源,并执行lightMode为ForwardBase的pass,其他光源都作为额外光源,使用LightMode为ForwardAdd的pass渲染多次并叠加。也就是说如果场景中有三个光源,一个平行光和两个其他光源,那么ForwardBase的pass会针对每个物体渲染一次,而ForwardAdd的pass会针对每个物体渲染两次。
采样混合即可,混合Func为gl.blendFunc(gl.ONE, gl.ONE);即直接加法叠加。所以要注意的是,自发光和环境光只能放到forwardBase中计算,否则会被叠加多次。设置好混合后并没有看到叠加效果,为什么?因为渲染同一个物体,由于深度相同,所以最终只有第一个pass的结果能看到。我不知道Unity具体是怎么实现的。我使用了polygon offset,每个灯光渲染时稍微便宜一下。貌似是应该可以通过修改投影矩阵来达到同样的效果。
直接使用material.renderPass渲染。
实现材质和前向渲染框架之后,顺手实现了两个材质作为测试。一是法线贴图,分别在切线空间和世界空间进行了计算,另外一个是基于RenderTexture的实时镜子效果。
视频如下:
mini3d.js 法线贴图演示
mini3d.js 实时RT镜子演示
在线体验