GameTech/URP 渲染实战
源码 / Ch12/Editor/SkinLutBaker.cs
Ch12/Editor/SkinLutBaker.cs
// 预积分皮肤散射 LUT(Penner 2011)的程序化生成。
// u 轴:N·L 从 -1 到 1;v 轴:曲率 1/r,从 0(平面)到 MaxInvRadius(单位 1/mm)。
// 散射剖面用 d'Eon & Luebke(GPU Gems 3 第 14 章)给出的 6 个高斯之和。
using System.IO;
using UnityEditor;
using UnityEngine;

namespace Cookbook.Ch12
{
    public static class SkinLutBaker
    {
        // 每个高斯的方差(mm²)与 RGB 权重
        static readonly float[] Variance = { 0.0064f, 0.0484f, 0.187f, 0.567f, 1.99f, 7.41f };
        static readonly Vector3[] Weight =
        {
            new Vector3(0.233f, 0.455f, 0.649f),
            new Vector3(0.100f, 0.336f, 0.344f),
            new Vector3(0.118f, 0.198f, 0.000f),
            new Vector3(0.113f, 0.007f, 0.007f),
            new Vector3(0.358f, 0.004f, 0.000f),
            new Vector3(0.078f, 0.000f, 0.000f),
        };

        // 散射剖面 R(d):距离入射点 d 毫米处的出射能量
        // 注意归一化用的是一维高斯 1/sqrt(2πv):我们只沿圆环(一维)积分,
        // 若用二维的 1/(2πv),宽高斯(红光的长距离散射)会被严重低估
        static Vector3 Profile(float d)
        {
            Vector3 r = Vector3.zero;
            for (int k = 0; k < 6; k++)
            {
                float v = Variance[k];
                float g = Mathf.Exp(-d * d / (2f * v)) / Mathf.Sqrt(2f * Mathf.PI * v);
                r += Weight[k] * g;
            }
            return r;
        }

        public const float MaxInvRadius = 1.0f;   // v = 1 对应半径 1mm

        // 在半径为 r 的圆环上积分:入射点与出射点夹角 x,弦长 2r·sin(x/2)
        // D(θ) = ∫ saturate(cos(θ + x)) R(2r sin(x/2)) dx / ∫ R(2r sin(x/2)) dx
        public static Color Integrate(float cosTheta, float invRadius)
        {
            float theta = Mathf.Acos(Mathf.Clamp(cosTheta, -1f, 1f));
            float r = 1f / Mathf.Max(invRadius, 1e-4f);
            Vector3 num = Vector3.zero, den = Vector3.zero;
            const int Steps = 256;
            float dx = 2f * Mathf.PI / Steps;
            for (int s = 0; s < Steps; s++)
            {
                float x = -Mathf.PI + (s + 0.5f) * dx;
                Vector3 p = Profile(Mathf.Abs(2f * r * Mathf.Sin(x * 0.5f)));
                num += p * Mathf.Max(0f, Mathf.Cos(theta + x));
                den += p;
            }
            return new Color(num.x / den.x, num.y / den.y, num.z / den.z, 1f);
        }

        public static Texture2D Generate(int width = 256, int height = 64)
        {
            var tex = new Texture2D(width, height, TextureFormat.RGBAHalf, false, true)   // 半精度 + 线性:暗部不出色带
            {
                name = "SkinLUT", wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Bilinear
            };
            var px = new Color[width * height];
            for (int y = 0; y < height; y++)
            {
                float invR = (y + 0.5f) / height * MaxInvRadius;
                for (int x = 0; x < width; x++)
                {
                    float ndl = (x + 0.5f) / width * 2f - 1f;
                    px[y * width + x] = Integrate(ndl, invR);
                }
            }
            tex.SetPixels(px);
            tex.Apply();
            return tex;
        }

        [MenuItem("Cookbook/Ch12/Bake Skin LUT")]
        static void Bake()
        {
            var tex = Generate();
            const string path = "Assets/Cookbook/Ch12/SkinLUT.png";
            // 存成 PNG 会量化到 8 位,暗部有轻微色带;要求高可改存 EXR(EncodeToEXR)
            var png = new Texture2D(tex.width, tex.height, TextureFormat.RGBA32, false, true);
            png.SetPixels(tex.GetPixels()); png.Apply();
            File.WriteAllBytes(path, png.EncodeToPNG());
            Object.DestroyImmediate(png);
            Object.DestroyImmediate(tex);
            AssetDatabase.ImportAsset(path);
            var imp = (TextureImporter)AssetImporter.GetAtPath(path);
            imp.sRGBTexture = false;              // LUT 是数据,不是颜色
            imp.wrapMode = TextureWrapMode.Clamp;
            imp.mipmapEnabled = false;
            imp.textureCompression = TextureImporterCompression.Uncompressed;
            imp.SaveAndReimport();
            Debug.Log("[Ch12] Skin LUT -> " + path);
        }
    }
}