案例 15.3半调网点(Halftone)进阶
目标:模拟报纸和漫画印刷的网点效果:单色版用黑色圆点表现明暗,彩色版用 C、M、Y、K 四色网点叠印出彩色。
原理:印刷机只能“有墨”或“没墨”,没法印出 50% 的灰。传统的调幅加网(AM Screening)的办法是:把画面分成规则的网格,每格印一个圆点,圆点的面积占格子面积的比例就是这一格的墨量。远看时人眼把点和空白平均起来,就感知到了灰度。
设格子边长为 1,墨量为 c(0..1),圆点半径 r 满足 πr² = c,所以:
注意是平方根关系:墨量从 0.25 涨到 1,半径只涨了一倍。直接拿墨量当半径,会让中间调整体偏暗。当 c 超过 π/4 ≈ 0.785 时,圆的半径超过半个格子,相邻圆点开始连在一起,格子四角还剩一点留白——这正是真实印刷品暗部的样子。
彩色印刷用青(C)、品红(M)、黄(Y)三色油墨做减色混合,再加一个黑(K)版。四个色版的网格要旋转不同的角度(经典网角是 C 15°、M 75°、Y 0°、K 45°),否则几层规则网格叠在一起会产生摩尔纹(Moiré)。另外,三色等量叠印出来的“灰”既费墨又发浑,所以印刷前会做黑版生成(Gray Component Replacement):把三色共有的那部分换成黑墨。
完整代码:
// 半调网点(Halftone):在屏幕上铺一张旋转过的网格,每格画一个圆点, // 圆点面积 = 该处“墨量”。单色模式只用黑墨;CMYK 模式四色各自用不同网角叠印。 Shader "Cookbook/Ch15/Halftone" { Properties { _BaseColor ("Base Color", Color) = (0.85, 0.3, 0.25, 1) [KeywordEnum(Off, Mono, CMYK)] _Style ("Style", Float) = 1 _CellSize ("Cell Size (pixels)", Range(3, 32)) = 8 _PaperColor ("Paper Color", Color) = (0.97, 0.95, 0.9, 1) _BlackGen ("Black Generation", Range(0, 1)) = 0.8 _Contrast ("Tone Contrast", Range(0.5, 2)) = 1.15 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" CBUFFER_START(UnityPerMaterial) half4 _BaseColor, _PaperColor; float _CellSize; half _BlackGen, _Contrast; CBUFFER_END ENDHLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } HLSLPROGRAM #pragma vertex NprVert #pragma fragment frag #pragma shader_feature_local_fragment _STYLE_OFF _STYLE_MONO _STYLE_CMYK #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH #include "Assets/Cookbook/Ch15/NPRCommon.hlsl" // 返回当前像素被网点覆盖的比例(0..1,已抗锯齿) // pixel:屏幕像素坐标;cell:网格边长(像素);angle:网角(度);ink:该处墨量 0..1 half HalftoneDot(float2 pixel, float cell, float angle, half ink) { float s, c; sincos(radians(angle), s, c); float2 p = mul(float2x2(c, -s, s, c), pixel) / cell; // 旋转后的网格坐标 float2 local = frac(p) - 0.5; // 格子内坐标,中心为 0 float d = length(local); // 圆面积 πr² 与格子面积 1 之比 = 墨量,所以半径与墨量的平方根成正比 float r = sqrt(saturate(ink) / PI); float aa = max(fwidth(d), 1e-4); // 约等于一个像素宽 return 1.0 - smoothstep(r - aa, r + aa, d); } half4 frag(NprVaryings i) : SV_Target { float3 N = normalize(i.normalWS); NprLight L = GetNprLight(i.positionWS, N); half3 lit = _BaseColor.rgb * (saturate(L.ndotl) * L.shadow * L.color + SampleSH(N)); lit = saturate(lit); #if defined(_STYLE_OFF) return half4(lit, 1); #else // 加一点对比度,印刷品的网点通常比连续色调“硬” lit = saturate((lit - 0.5h) * _Contrast + 0.5h); float2 px = i.positionCS.xy; #if defined(_STYLE_MONO) // 单色版只表现明暗:用光照本身的亮度,本色只轻微参与,否则深色物体会糊成一片灰 half3 lighting = saturate(L.ndotl) * L.shadow * L.color + SampleSH(N); half tone = Luminance(saturate(lighting)) * lerp(1.0h, Luminance(_BaseColor.rgb), 0.3h); half ink = 1.0h - saturate((tone - 0.5h) * _Contrast + 0.5h); half cover = HalftoneDot(px, _CellSize, 45, ink); return half4(lerp(_PaperColor.rgb, half3(0.08, 0.08, 0.1), cover), 1); #else // RGB → CMY,再把三色共同的“灰”部分交给黑墨(K),减少用墨、让暗部更干净 half3 cmy = 1.0h - lit; half k = min(cmy.r, min(cmy.g, cmy.b)) * _BlackGen; cmy = (cmy - k) / max(1.0h - k, 1e-3h); // 经典网角:C 15°、M 75°、Y 0°、K 45°,错开角度避免摩尔纹 half c = HalftoneDot(px, _CellSize, 15, cmy.r); half m = HalftoneDot(px, _CellSize, 75, cmy.g); half y = HalftoneDot(px, _CellSize, 0, cmy.b); half kk = HalftoneDot(px, _CellSize, 45, k); // 减色叠印:每种墨把纸的颜色乘上自己的透射色 half3 col = _PaperColor.rgb; col *= lerp(1.0h, half3(0.0, 0.66, 0.92), c); col *= lerp(1.0h, half3(0.92, 0.1, 0.55), m); col *= lerp(1.0h, half3(1.0, 0.92, 0.05), y); col *= lerp(1.0h, half3(0.1, 0.1, 0.12), kk); return half4(col, 1); #endif #endif } ENDHLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 HLSLPROGRAM #pragma vertex NprShadowVert #pragma fragment NprNullFrag #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW #include "Assets/Cookbook/Ch15/NPRCommon.hlsl" ENDHLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R HLSLPROGRAM #pragma vertex NprDepthVert #pragma fragment NprNullFrag #include "Assets/Cookbook/Ch15/NPRCommon.hlsl" ENDHLSL } } }
// 半调网点(Halftone):在屏幕上铺一张旋转过的网格,每格画一个圆点,(GLSL 版,对应 Ch15/Halftone.shader) // 圆点面积 = 该处“墨量”。单色模式只用黑墨;CMYK 模式四色各自用不同网角叠印。 Shader "Cookbook/Ch15/Halftone" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } GLSLINCLUDE uniform vec4 _BaseColor, _PaperColor; uniform float _CellSize; uniform float _BlackGen, _Contrast; ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } GLSLPROGRAM #pragma shader_feature_local_fragment _STYLE_OFF _STYLE_MONO _STYLE_CMYK #define NPR_VARYINGS #include "Ch15/NPRCommon.glsl" #ifdef VERTEX void main() { NprVert(); } #endif #ifdef FRAGMENT // 返回当前像素被网点覆盖的比例(0..1,已抗锯齿) // pixel:屏幕像素坐标;cell:网格边长(像素);angle:网角(度);ink:该处墨量 0..1 float HalftoneDot(vec2 pixel, float cell, float angle, float ink) { float s = sin(radians(angle)), c = cos(radians(angle)); // HLSL 的 mul(float2x2(c, -s, s, c), p):float2x2 按行构造,GLSL 的 mat2 按列,所以写成 p * mat2(...) vec2 p = (pixel * mat2(c, -s, s, c)) / cell; // 旋转后的网格坐标 vec2 local = fract(p) - 0.5; // 格子内坐标,中心为 0 float d = length(local); // 圆面积 πr² 与格子面积 1 之比 = 墨量,所以半径与墨量的平方根成正比 float r = sqrt(saturate(ink) / PI); float aa = max(fwidth(d), 1e-4); // 约等于一个像素宽 return 1.0 - smoothstep(r - aa, r + aa, d); } vec4 frag() { vec3 N = normalize(normalWS); NprLight L = GetNprLight(positionWS, N); vec3 lit = _BaseColor.rgb * (saturate(L.ndotl) * L.shadow * L.color + SampleSH(N)); lit = saturate(lit); #if defined(_STYLE_OFF) return vec4(lit, 1.0); #else // 加一点对比度,印刷品的网点通常比连续色调“硬” lit = saturate((lit - 0.5) * _Contrast + 0.5); vec2 px = gl_FragCoord.xy; // = HLSL 的 i.positionCS.xy(像素坐标) #if defined(_STYLE_MONO) // 单色版只表现明暗:用光照本身的亮度,本色只轻微参与,否则深色物体会糊成一片灰 vec3 lighting = saturate(L.ndotl) * L.shadow * L.color + SampleSH(N); float tone = Luminance(saturate(lighting)) * mix(1.0, Luminance(_BaseColor.rgb), 0.3); float ink = 1.0 - saturate((tone - 0.5) * _Contrast + 0.5); float cover = HalftoneDot(px, _CellSize, 45.0, ink); return vec4(mix(_PaperColor.rgb, vec3(0.08, 0.08, 0.1), cover), 1.0); #else // RGB → CMY,再把三色共同的“灰”部分交给黑墨(K),减少用墨、让暗部更干净 vec3 cmy = 1.0 - lit; float k = min(cmy.r, min(cmy.g, cmy.b)) * _BlackGen; cmy = (cmy - k) / max(1.0 - k, 1e-3); // 经典网角:C 15°、M 75°、Y 0°、K 45°,错开角度避免摩尔纹 float c = HalftoneDot(px, _CellSize, 15.0, cmy.r); float m = HalftoneDot(px, _CellSize, 75.0, cmy.g); float y = HalftoneDot(px, _CellSize, 0.0, cmy.b); float kk = HalftoneDot(px, _CellSize, 45.0, k); // 减色叠印:每种墨把纸的颜色乘上自己的透射色 vec3 col = _PaperColor.rgb; col *= mix(vec3(1.0), vec3(0.0, 0.66, 0.92), c); col *= mix(vec3(1.0), vec3(0.92, 0.1, 0.55), m); col *= mix(vec3(1.0), vec3(1.0, 0.92, 0.05), y); col *= mix(vec3(1.0), vec3(0.1, 0.1, 0.12), kk); return vec4(col, 1.0); #endif #endif } void main() { fragColor = frag(); } #endif ENDGLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 GLSLPROGRAM #define NPR_SHADOW_PASS #include "Ch15/NPRCommon.glsl" ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R GLSLPROGRAM #define NPR_DEPTH_PASS #include "Ch15/NPRCommon.glsl" ENDGLSL } } }
关键代码讲解:网点函数是整个效果的核心,输入像素坐标、网格大小、网角和墨量,输出覆盖率:
// 返回当前像素被网点覆盖的比例(0..1,已抗锯齿) // pixel:屏幕像素坐标;cell:网格边长(像素);angle:网角(度);ink:该处墨量 0..1 half HalftoneDot(float2 pixel, float cell, float angle, half ink) { float s, c; sincos(radians(angle), s, c); float2 p = mul(float2x2(c, -s, s, c), pixel) / cell; // 旋转后的网格坐标 float2 local = frac(p) - 0.5; // 格子内坐标,中心为 0 float d = length(local); // 圆面积 πr² 与格子面积 1 之比 = 墨量,所以半径与墨量的平方根成正比 float r = sqrt(saturate(ink) / PI); float aa = max(fwidth(d), 1e-4); // 约等于一个像素宽 return 1.0 - smoothstep(r - aa, r + aa, d); }
// 返回当前像素被网点覆盖的比例(0..1,已抗锯齿) // pixel:屏幕像素坐标;cell:网格边长(像素);angle:网角(度);ink:该处墨量 0..1 float HalftoneDot(vec2 pixel, float cell, float angle, float ink) { float s = sin(radians(angle)), c = cos(radians(angle)); // HLSL 的 mul(float2x2(c, -s, s, c), p):float2x2 按行构造,GLSL 的 mat2 按列,所以写成 p * mat2(...) vec2 p = (pixel * mat2(c, -s, s, c)) / cell; // 旋转后的网格坐标 vec2 local = fract(p) - 0.5; // 格子内坐标,中心为 0 float d = length(local); // 圆面积 πr² 与格子面积 1 之比 = 墨量,所以半径与墨量的平方根成正比 float r = sqrt(saturate(ink) / PI); float aa = max(fwidth(d), 1e-4); // 约等于一个像素宽 return 1.0 - smoothstep(r - aa, r + aa, d); }
- 先把像素坐标旋转、再除以格子大小,得到网格坐标;
frac(p) - 0.5是格子内以中心为原点的坐标。 fwidth(d)是 d 在相邻像素间的变化量,约等于“一个像素对应多少个格子单位”。用它做smoothstep的过渡宽度,圆点边缘就有一个像素宽的抗锯齿,且与格子大小无关。这是 Shader 里画任何程序化图形都适用的抗锯齿套路(第 4 章的 SDF 也是同样的思路)。
half4 frag(NprVaryings i) : SV_Target { float3 N = normalize(i.normalWS); NprLight L = GetNprLight(i.positionWS, N); half3 lit = _BaseColor.rgb * (saturate(L.ndotl) * L.shadow * L.color + SampleSH(N)); lit = saturate(lit); #if defined(_STYLE_OFF) return half4(lit, 1); #else // 加一点对比度,印刷品的网点通常比连续色调“硬” lit = saturate((lit - 0.5h) * _Contrast + 0.5h); float2 px = i.positionCS.xy; #if defined(_STYLE_MONO) // 单色版只表现明暗:用光照本身的亮度,本色只轻微参与,否则深色物体会糊成一片灰 half3 lighting = saturate(L.ndotl) * L.shadow * L.color + SampleSH(N); half tone = Luminance(saturate(lighting)) * lerp(1.0h, Luminance(_BaseColor.rgb), 0.3h); half ink = 1.0h - saturate((tone - 0.5h) * _Contrast + 0.5h); half cover = HalftoneDot(px, _CellSize, 45, ink); return half4(lerp(_PaperColor.rgb, half3(0.08, 0.08, 0.1), cover), 1); #else // RGB → CMY,再把三色共同的“灰”部分交给黑墨(K),减少用墨、让暗部更干净 half3 cmy = 1.0h - lit; half k = min(cmy.r, min(cmy.g, cmy.b)) * _BlackGen; cmy = (cmy - k) / max(1.0h - k, 1e-3h); // 经典网角:C 15°、M 75°、Y 0°、K 45°,错开角度避免摩尔纹 half c = HalftoneDot(px, _CellSize, 15, cmy.r); half m = HalftoneDot(px, _CellSize, 75, cmy.g); half y = HalftoneDot(px, _CellSize, 0, cmy.b); half kk = HalftoneDot(px, _CellSize, 45, k); // 减色叠印:每种墨把纸的颜色乘上自己的透射色 half3 col = _PaperColor.rgb; col *= lerp(1.0h, half3(0.0, 0.66, 0.92), c); col *= lerp(1.0h, half3(0.92, 0.1, 0.55), m); col *= lerp(1.0h, half3(1.0, 0.92, 0.05), y); col *= lerp(1.0h, half3(0.1, 0.1, 0.12), kk); return half4(col, 1); #endif #endif }
vec4 frag() { vec3 N = normalize(normalWS); NprLight L = GetNprLight(positionWS, N); vec3 lit = _BaseColor.rgb * (saturate(L.ndotl) * L.shadow * L.color + SampleSH(N)); lit = saturate(lit); #if defined(_STYLE_OFF) return vec4(lit, 1.0); #else // 加一点对比度,印刷品的网点通常比连续色调“硬” lit = saturate((lit - 0.5) * _Contrast + 0.5); vec2 px = gl_FragCoord.xy; // = HLSL 的 i.positionCS.xy(像素坐标) #if defined(_STYLE_MONO) // 单色版只表现明暗:用光照本身的亮度,本色只轻微参与,否则深色物体会糊成一片灰 vec3 lighting = saturate(L.ndotl) * L.shadow * L.color + SampleSH(N); float tone = Luminance(saturate(lighting)) * mix(1.0, Luminance(_BaseColor.rgb), 0.3); float ink = 1.0 - saturate((tone - 0.5) * _Contrast + 0.5); float cover = HalftoneDot(px, _CellSize, 45.0, ink); return vec4(mix(_PaperColor.rgb, vec3(0.08, 0.08, 0.1), cover), 1.0); #else // RGB → CMY,再把三色共同的“灰”部分交给黑墨(K),减少用墨、让暗部更干净 vec3 cmy = 1.0 - lit; float k = min(cmy.r, min(cmy.g, cmy.b)) * _BlackGen; cmy = (cmy - k) / max(1.0 - k, 1e-3); // 经典网角:C 15°、M 75°、Y 0°、K 45°,错开角度避免摩尔纹 float c = HalftoneDot(px, _CellSize, 15.0, cmy.r); float m = HalftoneDot(px, _CellSize, 75.0, cmy.g); float y = HalftoneDot(px, _CellSize, 0.0, cmy.b); float kk = HalftoneDot(px, _CellSize, 45.0, k); // 减色叠印:每种墨把纸的颜色乘上自己的透射色 vec3 col = _PaperColor.rgb; col *= mix(vec3(1.0), vec3(0.0, 0.66, 0.92), c); col *= mix(vec3(1.0), vec3(0.92, 0.1, 0.55), m); col *= mix(vec3(1.0), vec3(1.0, 0.92, 0.05), y); col *= mix(vec3(1.0), vec3(0.1, 0.1, 0.12), kk); return vec4(col, 1.0); #endif #endif }
彩色版先把线性 RGB 当成“反射率”转成 CMY 墨量(1 − RGB),提取三者的最小值乘以 _BlackGen 作为黑墨,再把剩余部分重新归一化。叠印时,每种墨按覆盖率把纸面颜色乘上自己的“透射色”——这就是减色混合:青墨吸收红光、品红吸收绿光、黄墨吸收蓝光。代码里的四种墨色故意没用纯 (0,1,1) 这类理想值,而是偏向真实油墨的色相,叠出来更像印刷品。
单色版另有一点讲究:如果直接用最终颜色的亮度算墨量,红色物体本身亮度就低,整个物体会糊成一片灰点,明暗反而看不出来。所以单色版只让本色轻微参与(30%),主要用光照本身的亮度。这是“风格化需要的不是物理正确,而是可读性”的又一个例子。

[KeywordEnum(Off, Mono, CMYK)] 声明风格,它会自动生成 _STYLE_OFF、_STYLE_MONO、_STYLE_CMYK 三个关键字,配合 #pragma shader_feature_local_fragment 只在片元阶段编译变体。在 C# 里切换时,除了 SetFloat("_Style", …),还要手动 EnableKeyword/DisableKeyword——Inspector 会替你做这件事,脚本不会。_CellSize 乘上 _ScreenParams.y / 1080 之类的比例。另外,网格太小(小于 4 像素)时圆点已经无法分辨形状,会退化成噪点;太大则丢失细节。对整个画面统一做半调,更适合放在后处理里(第 26 章),这里的做法优势在于可以只对某些物体生效。