threejs复刻原神渲染(一)

2,739 阅读11分钟

image.png

崩铁2.3已经更新了,不知道大家有没有抽到自己想要的角色,哥们已经梭哈流萤了!

扯远了,正题开始,因为经常逛知乎且身边做TA的朋友很多,最近不是很忙,就可以抽出一点点的时间来尝试试一下卡渲(Cel Shading),可是转念一想,原来我的unity还没学 ,那咋办,只能硬着头皮上了,用webgl浅浅尝试一下。

接下来就直接进入实战吧

1.准备工作

我们需要去模之屋或者github下载自己喜欢角色的模型,这边我使用的是做WebAr的demo所用到的神里绫华的模型,以及各种贴图

image.png 如果你是从模之屋下载的模型会发现贴图少了几张,都可以去github上白嫖,这几张都是比较重要的。接下来我们介绍一下这几张图

脸部SDF的阴影图 image.png

头发和身体的魔法图(光照图) image.pngimage.png

法线贴图同样也是分为头发和身体 image.pngimage.png

接下来我们需要将下载的模型导入blender,如果你从模之屋下载的模型应该是pmx格式,如果是从github下载的从游戏里解包的应该是fbx格式的,fbx的可以直接导入blender,但是pmx需要使用到mmd tools这个插件,下载zip文件打开blenedr-> 编辑->偏好设置->安装 选择你下载的mmd tools 并且打上勾就可以导入pmx模型了

image.png

导入模型后 着色方式选择材质预览看一下贴图有没有丢,如果丢失了的话需要自己手动贴一下,打开mmd 插件 选择转换给blender这样子着色器里的材质就变成了原理化BSDF

image.png

image.png

image.png

下一步就是合并材质,将使用相同贴图的材质合并为一个,碍于篇幅原因这里就不细说了,可以找3D同学帮忙处理下,合并完材质后,给材质命名方便后面修改其着色器,做好这些处理后就可以导出了,我这边选择导出的格式是glb,导出后可以去gltf-viewer看一下有没有问题,如果发现透明的问题需要去blender里设置材质的混合模式,不需要透明的可以选择不透明,之后在导出看一看没问题的话可以进行下一步了,记得导出切向和顶点色

image.png

2.正式开始

创建一个项目,我这边使用的是自己封装的r3f模板,大家根据自己的喜好起一个项目就可以了

之后把导出的模型渲染到场景里,PBR材质受光的影响 如果看到纯黑且控制台没有报错的话可以加光源就可以看到模型了

image.png 在没有cel Shading加持下有点不忍直视了

1.漫反射

因为需要修改原本的着色器,如果使用three自带的onBeforeCompileapi会写很多的replace,不方便,所以这里我采用的是Custom ShaderMaterial,为了屏蔽pbr的影响可以选择替换材质为MeshBasic或者是使用Emissive,我选的是后者,我们先把脸部的颜色设设置成白色,因为脸部有很多透明的部分,我们设置成frontSide即可

scene.traverse((child) => {
      if (child instanceof Mesh) {
        const mat = child.material as MeshStandardMaterial;
        mat.map!.colorSpace = SRGBColorSpace;
        if (mat.name == "face") {
          const newMat = new CustomShaderMaterial({
            baseMaterial: MeshStandardMaterial,
            vertexShader,
            fragmentShader: FacefragmentShader,
            uniforms,
            map: mat.map,
            silent: true,
            transparent: mat.transparent
          });
          child.material = newMat;
        } else {
          child.material = new CustomShaderMaterial({
            name: mat.name,
            baseMaterial: MeshStandardMaterial,
            color: mat.color,
            transparent: mat.transparent,
            map: mat.map,
            depthWrite: mat.depthWrite,
            depthTest: mat.depthTest,
            side: mat.side,
            silent: true,
            alphaTest: mat.alphaTest,
            uniforms,
            vertexShader,
            fragmentShader: OtherfragmentShader,
          });
        }
      }
    });

在脸部的片元着色器中将自发光颜色设置为漫反射贴图的颜色,如果是MeshBasicCOlor直接设置FragColor即可

csm_Emissive = csm_DiffuseColor.rgb;

在其他部位的片元着色其中我们直接输出贴图的颜色即可

csm_Emissive = csm_DiffuseColor.rgb;

image.png 结果已经出来了,都直接输出了漫反射贴图的颜色,如果你和我一样使用的是r3f的话会发现整个色调感觉灰蒙蒙的比较暗,这是因为r3f默认的toneMappingACESFilmicToneMapping,如果你介意这种色调映射的话可以改为NoToneMapping

 <Canvas
        frameloop={demand ? "never" : "always"}
        className="webgl"
        dpr={[1, 2]}
        camera={{
          fov: 50,
          near: 0.1,
          position: [0, 0, 2],
          far: 500,
        }}
        gl={{ toneMapping: NoToneMapping }}
      >
        {location.hash.includes("debug") && <Perf position="top-left" />}
        <Suspense fallback={null}>
          <Sketch />
        </Suspense>
      </Canvas>

image.png 对比起受光照模型影响的绫华和直接输出漫反射贴图的绫华,已经有一个质的飞跃了,这才是二次元该有的样子!

接下来是重头戏,添加漫反射,我们需要提前准备一下上下文

NDotL:lambert

NDotH:Blinn-Phong

NDotV:fresnel

在顶点着色器中:

attribute vec4 tangent;
varying vec2 vUv;
varying vec3 vWorldNormal;
varying vec3 vWorldTangent;
varying vec3 vWorldBitangent;
varying vec3 vDirWs;
varying vec3 vViewNormal;

void main() {
  vUv = uv;
  vWorldNormal = (modelMatrix * vec4(normal, 0.0)).xyz;
  vec3 transTangent = (modelMatrix * vec4(tangent.xyz, 0.0)).xyz;
  vWorldTangent = normalize(transTangent);
  vWorldBitangent = normalize(cross(vWorldNormal, vWorldTangent) * tangent.w);
  vViewNormal = (modelViewMatrix * vec4(normal, 0.0)).xyz;
  vec4 Ws = modelMatrix * vec4(position, 1.0);
  vDirWs = normalize(cameraPosition - Ws.xyz);
}

在片元着色器中:

varying vec2 vUv;
varying vec3 vWorldNormal;
varying vec3 vWorldTangent;
varying vec3 vWorldBitangent;
varying vec3 vDirWs;
varying vec3 vViewNormal;
uniform vec3 uLightPosition;
uniform sampler2D uLightMap;
uniform sampler2D uRampMap;
uniform sampler2D uNormalMap;

void main() {
  /* 处理提前准备的数据 */
  /* normalMap */
  vec4 normalTex = texture2D(uNormalMap, vUv);
  vec3 normalTs = vec3(normalTex.rg * 2. - 1., 0.);
  normalTs.z = sqrt(1. - dot(normalTs.xy, normalTs.xy));

  mat3 tbn = mat3(normalize(vWorldTangent), normalize(vWorldBitangent),  normalize(vWorldNormal));

  vec3 worldNormal = normalize(tbn * normalTs);

  vec3 dirL = normalize(uLightPosition);
  vec3 hDirWS = normalize(vDirWs + dirL);

  vec2 matcapUV = (normalize(vViewNormal.xy) + 1.) * .5;

  float NDotL = dot(worldNormal, dirL); //lambert

  NDotL = max(NDotL, 0.);

  float NDotH = dot(worldNormal, hDirWS); //Blinn-Phong

  float NdotV = dot(worldNormal, vDirWs); //fresnel

  /* lightMap */
  vec4 lightMapTex = texture2D(uLightMap, vUv);
  
  csm_Emissive = csm_DiffuseColor.rgb;
  
  csm_Roughness = 1.;
  
  csm_Metalness = 0.;
}

笔者这里光源是在外界模拟的一个点当做uniform传入的,同时我们传入我们上一步准备的好的一些贴图,分别是Ramp图,法线贴图,细心的朋友肯定发现了端倪,这个法线图怎么看起来和我们常用的那种不一样啊,不应该是蓝紫色的吗,但是这个法线贴图却呈现黄绿色,这是因为为了节省资源,法线贴图可以只保留两个通道,第三个通道通过单位向量的性质可以算出来,如果你把这涨法线图放去ps里就可以发现他的b通道存了另一张图,这里需要注意的是需要区分身体还是头发

 if (mat.name === "hair" || mat.name == "dress") {
            child.material.uniforms.uLightMap = new Uniform(hairLightMap);
            child.material.uniforms.uRampMap = new Uniform(hairRampMap);
            child.material.uniforms.uNormalMap = new Uniform(hairNormalMap);
            child.material.uniforms.uEmissiveMap = new Uniform(null);
          } else if (mat.name == "body") {
            child.material.uniforms.uLightMap = new Uniform(bodyLightMap);
            child.material.uniforms.uRampMap = new Uniform(bodyRampMap);
            child.material.uniforms.uNormalMap = new Uniform(bodyNormalMap);
            child.material.uniforms.uEmissiveMap = new Uniform(bodyEmissiveMap);
          }

image.png rgb通道的值域是[0-1],我们需要把他映射到[-1,1],法向量是单位向量,单位向量的模长是1,自然就可以求出第三个通道的值,因为法线贴图是中的法线是存在切线空间中的,我们需要构建tbn矩阵将他转换到我们需要的空间,这样子就可以从法线贴图上获取法线数据了

/* normalMap */
  vec4 normalTex = texture2D(uNormalMap, vUv);
  vec3 normalTs = vec3(normalTex.rg * 2. - 1., 0.);
  normalTs.z = sqrt(1. - dot(normalTs.xy, normalTs.xy));

  mat3 tbn = mat3(normalize(vWorldTangent), normalize(vWorldBitangent),      normalize(vWorldNormal));

  vec3 worldNormal = normalize(tbn * normalTs);

不理解tbn矩阵和切线空间的同学可以去查一查资料,这里由于篇幅原因就不讨论了

NDotL的值域是[-1,1],过滤掉小于0的部分,用半兰伯特让暗部没有那么死黑死黑的

  float halfLambert = pow(NDotL * .5 + .5, 2.);

image.png smoothStep让其出现明显的分界线并在两个边界值之间平滑过渡

float lamberStep = smoothstep(.42, .45, halfLambert);

image.png

接下来该采样Ramp图,在这之前需要着重介绍下这个重量级嘉宾 lightMap(魔法图)

image.png

Lightmap.r, 高光的枚举,区分金属与非金属,数值为1时为金属,小于1为非金属,非金属时其数值可以直接作为高光强度使用 image.png

Lightmap.g, 用于区分AO区域,AO就是常暗区域,有光照也是暗 image.png

Lightmap.b, 高光的形状,修饰高光的细节 image.png

Lightmap.a, 用于根据阈值采样Ramp图 image.png

image.png 阈值分为4个档次分别是[0,0.3] ,[0.3,0.5],[0.5,0.7],[0.7,1.0]

在片元着色器中枚举出这些阈值

 /* 枚举样条阈值 */
  float matEnum0 = .0;
  float matEnum1 = .3;
  float matEnum2 = .5;
  float matEnum3 = .7;
  float matEnum4 = 1.;

根据身体的漫反射贴图,ramp图和lightmap的alpha通道我们可以知道采样ramp图的顺序是1 4 3 5 2 同样也枚举出来 image.png

float RampMapRow0 = 1.;
float RampMapRow1 = 4.;
float RampMapRow2 = 3.;
float RampMapRow3 = 5.;
float RampMapRow4 = 2.;

为了让采样不靠近Ramp图的边缘我们需要计算每一行样条的中心点

/* 计算每一行样条的中心点 */
  float ramp0 = RampMapRow0 / 10. - .05;
  float ramp1 = RampMapRow1 / 10. - .05;
  float ramp2 = RampMapRow2 / 10. - .05;
  float ramp3 = RampMapRow3 / 10. - .05;
  float ramp4 = RampMapRow4 / 10. - .05;

根据魔法图a通道的阈值我们需要计算rampV,通过mix去混合这4个档次

/* 根据魔法图alpha通道的阈值 计算rampV */
  float dayRampV = mix(ramp4, ramp3, step(lightMapTex.a, (matEnum4 + matEnum3) * .5));
  dayRampV = mix(dayRampV, ramp2, step(lightMapTex.a, (matEnum3 + matEnum2) * .5));
  dayRampV = mix(dayRampV, ramp1, step(lightMapTex.a, (matEnum2 + matEnum1) * .5));
  dayRampV = mix(dayRampV, ramp0, step(lightMapTex.a, (matEnum1 + matEnum0) * .5));

**为什么是两个枚举值相加除2呢因为要计算两档次间的中点 **

image.png

ramp图上5行是白天 下5行是夜晚 所以夜晚的rampV0.5就可以了,又因为我们是以左上角为为原点但是uv坐标原点在左下角所以v坐标需要用一减去当前坐标,同时为了防止横轴采样到边界我们需要限制范围并在一定范围内平滑过渡,通过面板来控制是白天还是晚上,因为值域是[-1,1],也需要映射到[0,1],之后混合白天阴影颜色和夜晚的阴影颜色

  float nightRampV = dayRampV + .5;

  float rampClampMin = .003;
  float rampClampMax = .997;

  /* 防止取到边界 */
  float rampU = clamp(smoothstep(.2, .4, halfLambert), rampClampMin, rampClampMax);
  vec2 dayUV = vec2(rampU, 1. - dayRampV);
  vec2 nightUV = vec2(rampU, 1. - nightRampV);

  vec2 darkDayUV = vec2(rampClampMin, 1. - dayRampV);
  vec2 darkNightUV = vec2(rampClampMin, 1. - nightRampV);

  float uIsDay = (uIsDay + 1.) * .5;
  vec3 rampGreyColor = mix(texture2D(uRampMap, nightUV).rgb, texture2D(uRampMap, dayUV).rgb, uIsDay);
  vec3 rampDarkColor = mix(texture2D(uRampMap, darkNightUV).rgb, texture2D(uRampMap, darkDayUV).rgb, uIsDay);

之后再乘以基础色和自定义的阴影颜色就可以得到灰色阴影和黑色阴影的颜色了

  vec3 grayShadowColor = baseColor.rgb * rampGreyColor * uShadowColor;
  vec3 darkShadowColor = baseColor.rgb * rampDarkColor * uShadowColor;

灰色阴影image.png

黑色阴影image.png

先用半兰伯特混合灰色阴影和亮部的颜色

vec3 diffuse = vec3(0.);
  diffuse = mix(grayShadowColor, baseColor.rgb, lamberStep);

因为lightmap的g通道只有大于0.5的部分受光照影响,所以我们需要用黑色阴影去混合漫反射的颜色

diffuse = mix(darkShadowColor, diffuse, clamp(lightMapTex.g * 2., 0., 1.));

最后是将常亮部分混合

diffuse = mix(diffuse, baseColor.rgb, clamp((lightMapTex.g - .5), 0., 1.) * 2.);

至此我们的漫反射也就完成了,各位可以通过改变光照的方向和切换白天或者夜晚仔细观察一下,对比对比

白天image.png

夜晚image.png

QQ2024626-10546-ezgif.com-video-to-gif-converter.gif

片元着色器完整代码

varying vec2 vUv;
varying vec3 vWorldNormal;
varying vec3 vWorldTangent;
varying vec3 vWorldBitangent;
varying vec3 vDirWs;
varying vec3 vViewNormal;
uniform vec3 uLightPosition;
uniform sampler2D uLightMap;
uniform sampler2D uRampMap;
uniform sampler2D uNormalMap;
uniform float uIsDay;
uniform vec3 uShadowColor;


float RampMapRow0 = 1.;
float RampMapRow1 = 4.;
float RampMapRow2 = 3.;
float RampMapRow3 = 5.;
float RampMapRow4 = 2.;

void main() {
  /* 处理需要的数据 */

  /* normalMap */
  vec4 normalTex = texture2D(uNormalMap, vUv);
  vec3 normalTs = vec3(normalTex.rg * 2. - 1., 0.);
  normalTs.z = sqrt(1. - dot(normalTs.xy, normalTs.xy));

  mat3 tbn = mat3(normalize(vWorldTangent), normalize(vWorldBitangent), normalize(vWorldNormal));

  vec3 worldNormal = normalize(tbn * normalTs);

  vec3 dirL = normalize(uLightPosition);
  vec3 hDirWS = normalize(vDirWs + dirL);

  vec2 matcapUV = (normalize(vViewNormal.xy) + 1.) * .5;

  float NDotL = dot(worldNormal, dirL); //lambert

  NDotL = max(NDotL, 0.);

  float NDotH = dot(worldNormal, hDirWS); //Blinn-Phong

  float NdotV = dot(worldNormal, vDirWs); //fresnel

  /* lightMap */
  vec4 lightMapTex = texture2D(uLightMap, vUv);

  float halfLambert = pow(NDotL * .5 + .5, 2.);
  float lamberStep = smoothstep(.42, .45, halfLambert);

  /* 枚举样条阈值 */
  float matEnum0 = .0;
  float matEnum1 = .3;
  float matEnum2 = .5;
  float matEnum3 = .7;
  float matEnum4 = 1.;

  /* 计算每一行样条的中心点 */
  float ramp0 = RampMapRow0 / 10. - .05;
  float ramp1 = RampMapRow1 / 10. - .05;
  float ramp2 = RampMapRow2 / 10. - .05;
  float ramp3 = RampMapRow3 / 10. - .05;
  float ramp4 = RampMapRow4 / 10. - .05;

  /* 根据魔法图alpha通道的阈值 计算rampV */
  float dayRampV = mix(ramp4, ramp3, step(lightMapTex.a, (matEnum4 + matEnum3) * .5));
  dayRampV = mix(dayRampV, ramp2, step(lightMapTex.a, (matEnum3 + matEnum2) * .5));
  dayRampV = mix(dayRampV, ramp1, step(lightMapTex.a, (matEnum2 + matEnum1) * .5));
  dayRampV = mix(dayRampV, ramp0, step(lightMapTex.a, (matEnum1 + matEnum0) * .5));

  float nightRampV = dayRampV + .5;

  float rampClampMin = .003;
  float rampClampMax = .997;

  /* 防止取到边界 */
  float rampU = clamp(smoothstep(.2, .4, halfLambert), rampClampMin, rampClampMax);
  vec2 dayUV = vec2(rampU, 1. - dayRampV);
  vec2 nightUV = vec2(rampU, 1. - nightRampV);

  vec2 darkDayUV = vec2(rampClampMin, 1. - dayRampV);
  vec2 darkNightUV = vec2(rampClampMin, 1. - nightRampV);

  float uIsDay = (uIsDay + 1.) * .5;
  vec3 rampGreyColor = mix(texture2D(uRampMap, nightUV).rgb, texture2D(uRampMap, dayUV).rgb, uIsDay);
  vec3 rampDarkColor = mix(texture2D(uRampMap, darkNightUV).rgb, texture2D(uRampMap, darkDayUV).rgb, uIsDay);

  vec4 baseColor = csm_DiffuseColor;

  vec3 grayShadowColor = baseColor.rgb * rampGreyColor * uShadowColor;
  vec3 darkShadowColor = baseColor.rgb * rampDarkColor * uShadowColor;

  /* light.g > 0.5的部分受光照影响 */
  vec3 diffuse = vec3(0.);
  diffuse = mix(grayShadowColor, baseColor.rgb, lamberStep);
  diffuse = mix(darkShadowColor, diffuse, clamp(lightMapTex.g * 2., 0., 1.));
  diffuse = mix(diffuse, baseColor.rgb, clamp((lightMapTex.g - .5), 0., 1.) * 2.);

  if(baseColor.a < .5) {
    discard;
  }

  csm_Emissive = vec3(diffuse);
  csm_Roughness = 1.;
  csm_Metalness = 0.;
}

项目地址:github.com/KallkaGo/To…

Ending

本文到这里就结束了,感谢知乎和b站等平台的各位大佬的文章和视频,网上没有系统的教程都是一步一个坑踩过来的,希望能给到大家一些帮助,谢谢