URP 渲染实战 / 第 12 章 PBR 进阶材质 / 案例 12.7
案例 12.7背光的树叶进阶
目标:做一组双面的程序化树叶,对比无透射、均匀透射、按叶脉厚度透射三种情况。
叶片形状和叶脉都在 Shader 里程序生成:UV 的 y 从叶柄到叶尖,宽度按正弦的幂变化,再乘一个线性收窄;主脉是 x = 0 附近的一条细线,侧脉是斜向重复的条纹。叶脉比叶肉厚,透光少:
Assets/Cookbook/Ch12/Translucent.shader(节选:leaf)
// 程序化叶片:UV 的 y 从叶柄(0)到叶尖(1)。返回 x=是否在叶内(符号距离),y=叶脉强度(0~1) float2 LeafShape(float2 uv) { float x = uv.x - 0.5, y = uv.y; float halfWidth = 0.44 * pow(saturate(sin(PI * y)), 0.75) * (1.0 - 0.35 * y); float inside = halfWidth - abs(x); float mid = 1.0 - smoothstep(0.004, 0.018, abs(x)); // 主脉 float f = frac((y - abs(x) * 0.9) * 7.0); // 斜向重复的侧脉 float side = 1.0 - smoothstep(0.03, 0.09, min(f, 1.0 - f)); side *= smoothstep(0.02, 0.06, abs(x)) * smoothstep(0.0, 0.1, inside); // 侧脉,离边缘淡出 return float2(inside, saturate(max(mid, side * 0.65))); }
// 程序化叶片:UV 的 y 从叶柄(0)到叶尖(1)。返回 x=是否在叶内(符号距离),y=叶脉强度(0~1) vec2 LeafShape(vec2 uv) { float x = uv.x - 0.5, y = uv.y; float halfWidth = 0.44 * pow(saturate(sin(PI * y)), 0.75) * (1.0 - 0.35 * y); float inside = halfWidth - abs(x); float mid = 1.0 - smoothstep(0.004, 0.018, abs(x)); // 主脉 float f = fract((y - abs(x) * 0.9) * 7.0); // 斜向重复的侧脉 float side = 1.0 - smoothstep(0.03, 0.09, min(f, 1.0 - f)); side *= smoothstep(0.02, 0.06, abs(x)) * smoothstep(0.0, 0.1, inside); // 侧脉,离边缘淡出 return vec2(inside, saturate(max(mid, side * 0.65))); }
Assets/Cookbook/Ch12/Translucent.shader(节选:shade)
half3 ShadeOneLight(MaterialCtx m, Light light) { float3 L = light.direction; float NoL = saturate(dot(m.N, L)); float3 front = (m.albedo * ADV_INV_PI + AdvSpecularGGX(m.N, m.V, L, m.a, 0.04)) * PI * NoL; // 透射:把光方向沿法线“扭”一点(模拟光在叶片内部的散射偏折), // 再看视线与 -L' 的接近程度——正对着光源看时最亮 float3 Lt = normalize(L + m.N * _Distortion); float VoLt = pow(saturate(dot(m.V, -Lt)), _TransPower) * _TransScale; float3 trans = (VoLt + _TransAmbient) * (1.0 - m.thickness) * _TransColor.rgb * m.albedo * _Transmission; // 透射光走的是“叶片背面”的阴影:正面阴影图对它没有意义,这里只用距离衰减 return front * light.color * (light.distanceAttenuation * light.shadowAttenuation) + trans * light.color * light.distanceAttenuation; }
vec3 ShadeOneLight(MaterialCtx m, Light light) { vec3 L = light.direction; float NoL = saturate(dot(m.N, L)); vec3 front = (m.albedo * ADV_INV_PI + AdvSpecularGGX(m.N, m.V, L, m.a, 0.04)) * PI * NoL; // 透射:把光方向沿法线“扭”一点(模拟光在叶片内部的散射偏折), // 再看视线与 -L' 的接近程度——正对着光源看时最亮 vec3 Lt = normalize(L + m.N * _Distortion); float VoLt = pow(saturate(dot(m.V, -Lt)), _TransPower) * _TransScale; vec3 trans = (VoLt + _TransAmbient) * (1.0 - m.thickness) * _TransColor.rgb * m.albedo * _Transmission; // 透射光走的是“叶片背面”的阴影:正面阴影图对它没有意义,这里只用距离衰减 return front * light.color * (light.distanceAttenuation * light.shadowAttenuation) + trans * light.color * light.distanceAttenuation; }
透射项没有乘阴影衰减:从正面看,叶片背后接收的光才是透射的来源,而正面的阴影图查询结果反映的是“正面是否被挡”,用在这里没有意义(单片叶子时,它甚至会被自己挡住)。更完整的做法是从光源一侧采样阴影图,得到“光穿进物体之前是否被挡”,那需要把采样点挪到背面,留作练习。
叶片双面渲染(Cull Off)时,背面的法线要翻转,否则背面的光照是反的。片元着色器可以用 SV_IsFrontFace 语义拿到当前片元是否是正面。形状是程序化裁剪的,URP 自带的 ShadowCaster 不知道这件事,阴影会是一个方块,所以这里手写了一个带 clip 的阴影 Pass,写法与 URP 的 ShadowCasterPass.hlsl 一致(法线偏移、点光源/方向光两种光方向、近裁剪面钳制):
Assets/Cookbook/Ch12/Translucent.shader(节选:shadowpass)
// 叶片形状是程序化裁剪的,URP 自带的 ShadowCaster 不知道,所以自己写一个带 clip 的 Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_instancing #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl" float3 _LightDirection; float3 _LightPosition; struct A { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct V { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; V vert(A v) { UNITY_SETUP_INSTANCE_ID(v); V o; float3 positionWS = TransformObjectToWorld(v.positionOS.xyz); float3 normalWS = TransformObjectToWorldNormal(v.normalOS); #if _CASTING_PUNCTUAL_LIGHT_SHADOW float3 L = normalize(_LightPosition - positionWS); #else float3 L = _LightDirection; #endif o.positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, L)); #if UNITY_REVERSED_Z o.positionCS.z = min(o.positionCS.z, UNITY_NEAR_CLIP_VALUE); #else o.positionCS.z = max(o.positionCS.z, UNITY_NEAR_CLIP_VALUE); #endif o.uv = v.uv; return o; } half4 frag(V i) : SV_Target { clip(LeafShape(i.uv).x); return 0; } ENDHLSL }
// 叶片形状是程序化裁剪的,URP 自带的 ShadowCaster 不知道,所以自己写一个带 clip 的 Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull Off GLSLPROGRAM VARYING vec2 uv; #ifdef VERTEX void main() { vec3 positionWS = TransformObjectToWorld(positionOS.xyz); vec3 normalWS = TransformObjectToWorldNormal(normalOS); // _CASTING_PUNCTUAL_LIGHT_SHADOW 在 GLSL 版里是个 uniform vec3 L = _CastingPunctualLightShadow > 0.5 ? normalize(_LightPosition - positionWS) : _LightDirection; vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, L)); // WebGL 深度不反转(UNITY_REVERSED_Z = 0),走 max 分支 positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE * positionCS.w); uv = texcoord0.xy; gl_Position = positionCS; } #endif #ifdef FRAGMENT void main() { if (LeafShape(uv).x < 0.0) discard; fragColor = vec4(0.0); } #endif ENDGLSL }

扩展:用同一个透射项做玉石和蜡烛
把透射项用在封闭的实体上(玉雕、蜡烛、皮肤的耳朵),关键在厚度。一个常见的离线做法是“反向 AO”:在每个顶点处沿法线的反方向朝物体内部发射一圈射线,测量平均穿出距离,归一化后存进顶点色。厚处透得少、薄处透得多,配合附加光(例如把点光源放在玉雕背后),就有很不错的“通透”感。HDRP 的 Thickness Map 与本节的厚度含义相同。