GameTech/URP 渲染实战
源码 / _Core/Editor/ShotRunner.cs
_Core/Editor/ShotRunner.cs
// 批处理入口:编译检查本章所有 Shader,并渲染本章所有 [BookShot] 配图。
// Unity -batchmode -projectPath <工程> -executeMethod Cookbook.Core.ShotRunner.Run -chapters ch05,ch06 [-shot id] [-noshots] -outdir <目录> -quit
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using Object = UnityEngine.Object;

namespace Cookbook.Core
{
    public static class ShotRunner
    {
        static readonly List<string> Errors = new List<string>();
        static Cubemap s_Sky;

        static string Arg(string name, string def = null)
        {
            var a = Environment.GetCommandLineArgs();
            for (int i = 0; i < a.Length - 1; i++) if (a[i] == name) return a[i + 1];
            return def;
        }
        static bool Flag(string name) => Environment.GetCommandLineArgs().Contains(name);

        public static void Run()
        {
            int code = 0;
            try { code = RunInner(); }
            catch (Exception e) { Debug.LogError("[Cookbook] Runner crashed: " + e); code = 2; }
            EditorApplication.Exit(code);
        }

        static int RunInner()
        {
            var chapters = (Arg("-chapters", "") ?? "").ToLowerInvariant().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
            string onlyShot = Arg("-shot");
            string outDir = Arg("-outdir", Path.GetFullPath(Path.Combine(Application.dataPath, "../../src/img")));
            Directory.CreateDirectory(outDir);
            PlayerSettings.colorSpace = ColorSpace.Linear;
            ShaderUtil.allowAsyncCompilation = false;
            Application.logMessageReceived += OnLog;

            bool InChapters(string s) => chapters.Length == 0 || chapters.Any(c => s.ToLowerInvariant().Contains("/" + c) || s.ToLowerInvariant().StartsWith(c));

            // 1) Shader 编译检查
            var shaderPaths = AssetDatabase.FindAssets("t:Shader", new[] { "Assets/Cookbook" })
                .Select(AssetDatabase.GUIDToAssetPath).Where(p => InChapters(p)).ToList();
            foreach (var p in shaderPaths) CheckShader(p);
            Debug.Log($"[Cookbook] Checked {shaderPaths.Count} shaders");

            // 2) 渲染配图
            if (!Flag("-noshots"))
            {
                var shots = AppDomain.CurrentDomain.GetAssemblies()
                    .SelectMany(a => { try { return a.GetTypes(); } catch { return new Type[0]; } })
                    .SelectMany(t => t.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
                    .Select(m => (m, attr: m.GetCustomAttribute<BookShotAttribute>()))
                    .Where(x => x.attr != null && InChapters(x.attr.Id) && (onlyShot == null || x.attr.Id == onlyShot))
                    .OrderBy(x => x.attr.Id).ToList();
                foreach (var (m, attr) in shots)
                {
                    try { RenderShot(m, attr, outDir); }
                    catch (Exception e) { Errors.Add($"SHOT {attr.Id}: {(e.InnerException ?? e).Message}\n{(e.InnerException ?? e).StackTrace}"); }
                }
                Debug.Log($"[Cookbook] Rendered {shots.Count} shots");
            }

            Application.logMessageReceived -= OnLog;
            var sb = new StringBuilder();
            foreach (var e in Errors.Distinct()) sb.AppendLine("COOKBOOK-ERROR " + e);
            sb.AppendLine($"COOKBOOK-SUMMARY errors={Errors.Distinct().Count()}");
            Debug.Log(sb.ToString());
            File.WriteAllText(Path.Combine(outDir, "_last_report.txt"), sb.ToString());
            return Errors.Count > 0 ? 1 : 0;
        }

        static void OnLog(string msg, string stack, LogType type)
        {
            if (type == LogType.Error || type == LogType.Exception || type == LogType.Assert)
            {
                if (msg.StartsWith("[Licensing") || msg.Contains("Licensing::")) return;
                Errors.Add("LOG " + msg);
            }
        }

        static void CheckShader(string path)
        {
            var sh = AssetDatabase.LoadAssetAtPath<Shader>(path);
            if (sh == null) { Errors.Add("SHADER load failed " + path); return; }
            var mat = new Material(sh);
            for (int i = 0; i < mat.passCount; i++) ShaderUtil.CompilePass(mat, i, true);
            foreach (var m in ShaderUtil.GetShaderMessages(sh))
                if (m.severity == UnityEditor.Rendering.ShaderCompilerMessageSeverity.Error)
                    Errors.Add($"SHADER {path}:{m.line} {m.message} [{m.platform}] {m.messageDetails}");
            Object.DestroyImmediate(mat);
        }

        // ---------------- 渲染 ----------------
        static void RenderShot(MethodInfo method, BookShotAttribute attr, string outDir)
        {
            var panels = new List<Texture2D>();
            for (int p = 0; p < attr.Panels; p++)
            {
                EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
                var s = NewShot(p);
                method.Invoke(null, new object[] { s });
                panels.Add(Capture(s, attr.W, attr.H, attr.Hdr));
                foreach (var o in s.Temp) if (o != null) Object.DestroyImmediate(o);
            }
            const int gap = 8;
            int w = attr.W * panels.Count + gap * (panels.Count - 1);
            var outTex = new Texture2D(w, attr.H, TextureFormat.RGB24, false);
            var white = Enumerable.Repeat(new Color32(255, 255, 255, 255), w * attr.H).ToArray();
            outTex.SetPixels32(white);
            for (int i = 0; i < panels.Count; i++)
            {
                outTex.SetPixels(i * (attr.W + gap), 0, attr.W, attr.H, panels[i].GetPixels());
                Object.DestroyImmediate(panels[i]);
            }
            outTex.Apply();
            File.WriteAllBytes(Path.Combine(outDir, attr.Id + ".jpg"), outTex.EncodeToJPG(88));
            Object.DestroyImmediate(outTex);
            Debug.Log("[Cookbook] shot " + attr.Id);
        }

        static Shot NewShot(int panel)
        {
            var s = new Shot { Panel = panel };
            var data = ScriptableObject.CreateInstance<UniversalRendererData>();
            ResourceReloader.ReloadAllNullIn(data, UniversalRenderPipelineAsset.packagePath);
            data.postProcessData = AssetDatabase.LoadAssetAtPath<PostProcessData>(UniversalRenderPipelineAsset.packagePath + "/Runtime/Data/PostProcessData.asset");
            data.renderingMode = RenderingMode.ForwardPlus;
            data.copyDepthMode = CopyDepthMode.AfterOpaques;   // 透明物体读到的是本帧深度
            var asset = UniversalRenderPipelineAsset.Create(data);
            asset.supportsCameraDepthTexture = true;
            asset.supportsCameraOpaqueTexture = true;
            asset.supportsHDR = true;
            asset.msaaSampleCount = 4;
            asset.shadowDistance = 30f;
            asset.shadowCascadeCount = 2;
            s.Asset = asset; s.Renderer = data;
            s.AssetProp("m_SoftShadowsSupported", true);
            s.AssetProp("m_AdditionalLightShadowsSupported", true);
            s.AssetProp("m_MainLightShadowmapResolution", 4096);
            s.Temp.Add(asset); s.Temp.Add(data);
            GraphicsSettings.defaultRenderPipeline = asset;
            QualitySettings.renderPipeline = null;

            var camGo = new GameObject("Main Camera") { tag = "MainCamera" };
            s.Cam = camGo.AddComponent<Camera>();
            s.Cam.allowHDR = true; s.Cam.allowMSAA = true;
            s.Cam.nearClipPlane = 0.05f; s.Cam.farClipPlane = 200f;
            s.CamData = camGo.AddComponent<UniversalAdditionalCameraData>();
            s.CamData.renderPostProcessing = false;
            s.LookAt(new Vector3(0, 1.4f, -4.2f), new Vector3(0, 0.55f, 0));

            var sunGo = new GameObject("Sun");
            s.Sun = sunGo.AddComponent<Light>();
            s.Sun.type = LightType.Directional; s.Sun.shadows = LightShadows.Soft; s.Sun.color = new Color(1f, 0.96f, 0.9f);
            sunGo.AddComponent<UniversalAdditionalLightData>();
            s.SunDir(50, -30);
            RenderSettings.sun = s.Sun;

            SetupEnvironment(s);
            return s;
        }

        // 程序化天空:同时作为背景、环境光(球谐)和默认反射
        static Vector3 SkyRadiance(Vector3 d)
        {
            Color zenith = new Color(0.12f, 0.25f, 0.55f), horizon = new Color(0.55f, 0.6f, 0.68f), ground = new Color(0.2f, 0.19f, 0.18f);
            Color c;
            if (d.y >= 0) c = Color.Lerp(horizon, zenith, Mathf.Pow(d.y, 0.45f));
            else c = Color.Lerp(horizon * 0.6f, ground, Mathf.Clamp01(-d.y * 4f));
            Vector3 sunDir = Quaternion.Euler(50, -30, 0) * Vector3.back;
            float sd = Mathf.Max(0, Vector3.Dot(d, sunDir));
            Vector3 v = new Vector3(c.r, c.g, c.b);
            v += new Vector3(1f, 0.9f, 0.75f) * (Mathf.Pow(sd, 64f) * 1.5f + Mathf.Pow(sd, 2000f) * 40f);
            return v;
        }

        static void SetupEnvironment(Shot s)
        {
            if (s_Sky == null)
            {
                const int size = 128;
                s_Sky = new Cubemap(size, TextureFormat.RGBAHalf, true) { name = "CookbookSky", hideFlags = HideFlags.DontSave };
                var faces = new[] { CubemapFace.PositiveX, CubemapFace.NegativeX, CubemapFace.PositiveY, CubemapFace.NegativeY, CubemapFace.PositiveZ, CubemapFace.NegativeZ };
                var px = new Color[size * size];
                foreach (var f in faces)
                {
                    for (int y = 0; y < size; y++)
                        for (int x = 0; x < size; x++)
                        {
                            float u = (x + 0.5f) / size * 2 - 1, v = (y + 0.5f) / size * 2 - 1;
                            Vector3 d;
                            switch (f)
                            {
                                case CubemapFace.PositiveX: d = new Vector3(1, -v, -u); break;
                                case CubemapFace.NegativeX: d = new Vector3(-1, -v, u); break;
                                case CubemapFace.PositiveY: d = new Vector3(u, 1, v); break;
                                case CubemapFace.NegativeY: d = new Vector3(u, -1, -v); break;
                                case CubemapFace.PositiveZ: d = new Vector3(u, -v, 1); break;
                                default: d = new Vector3(-u, -v, -1); break;
                            }
                            var r = SkyRadiance(d.normalized);
                            px[y * size + x] = new Color(r.x, r.y, r.z, 1);
                        }
                    s_Sky.SetPixels(px, f);
                }
                s_Sky.Apply(true, false);
            }
            var skyMat = new Material(Shader.Find("Skybox/Cubemap"));
            skyMat.SetTexture("_Tex", s_Sky);
            s.Temp.Add(skyMat);
            RenderSettings.skybox = skyMat;
            RenderSettings.defaultReflectionMode = DefaultReflectionMode.Custom;
            RenderSettings.customReflectionTexture = s_Sky;
            RenderSettings.reflectionIntensity = 1f;
            RenderSettings.ambientMode = AmbientMode.Skybox;
            RenderSettings.fog = false;

            var sh = new SphericalHarmonicsL2();
            var rnd = new System.Random(7);
            const int N = 4096;
            for (int i = 0; i < N; i++)
            {
                float z = (float)rnd.NextDouble() * 2 - 1, a = (float)rnd.NextDouble() * Mathf.PI * 2, r = Mathf.Sqrt(1 - z * z);
                var d = new Vector3(r * Mathf.Cos(a), z, r * Mathf.Sin(a));
                var L = SkyRadiance(d);
                L = Vector3.Min(L, Vector3.one * 3f); // 太阳尖峰交给方向光,不重复计入环境光
                sh.AddDirectionalLight(d, new Color(L.x, L.y, L.z), 4f / N);
            }
            RenderSettings.ambientProbe = sh;
            s.Cam.clearFlags = CameraClearFlags.Skybox;
        }

        static Texture2D Capture(Shot s, int w, int h, bool hdr)
        {
            var desc = new RenderTextureDescriptor(w, h, RenderTextureFormat.ARGB32, 24) { sRGB = true, msaaSamples = 1 };
            var rt = new RenderTexture(desc);
            rt.Create();
            // 相机目标:URP 14 按目标纹理决定中间缓冲的格式与 MSAA,所以这里直接给 4x MSAA(HDR 时用浮点)
            var camDesc = hdr
                ? new RenderTextureDescriptor(w, h, RenderTextureFormat.ARGBHalf, 24) { sRGB = false, msaaSamples = 4 }
                : new RenderTextureDescriptor(w, h, RenderTextureFormat.ARGB32, 24) { sRGB = true, msaaSamples = 4 };
            var hdrRt = new RenderTexture(camDesc);
            hdrRt.Create();
            s.Cam.targetTexture = hdrRt;
            s.Cam.aspect = (float)w / h;
            for (int i = 0; i < 3; i++) s.Cam.Render();   // 多渲染几次,让阴影/后处理/历史帧稳定
            var prev = RenderTexture.active;
            Graphics.Blit(hdrRt, rt);                       // 解析 MSAA;HDR 时顺便线性浮点 -> sRGB 8 位
            RenderTexture.active = rt;
            var tex = new Texture2D(w, h, TextureFormat.RGBA32, false) { hideFlags = HideFlags.DontSave };
            tex.ReadPixels(new Rect(0, 0, w, h), 0, 0);
            tex.Apply();
            RenderTexture.active = prev;
            s.Cam.targetTexture = null;
            rt.Release(); Object.DestroyImmediate(rt);
            hdrRt.Release(); Object.DestroyImmediate(hdrRt);
            return tex;
        }
    }
}