#if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; using UnityEngine.SceneManagement; using Object = UnityEngine.Object; /// Authors HomeCamp through Pipeline; never constructs the scene at runtime. public static class BuildHomeCamp { const string Root = "Assets/Bundles/Scenes/Town/HomeCamp"; const string Shared = "Assets/Bundles/Scenes/Common"; const string PrefabPath = Root + "/HomeCamp.prefab"; const string ScenePath = Root + "/HomeCamp.unity"; const int Layer = 29; const float GroundHalfSize = 21; const float StoneTileMeters = 3.2f; static readonly string[] Parts = { "ground", "mainhouse", "cottage", "fountain" }; static readonly string[] Props = { "fence", "stall", "flowerpot", "rocks", "steps", "cypress", "bush", "flowercluster", "shadetree" }; // Studio HomeCamp props were exported facing -Z. DGX static meshes use the project +Z front, so the prefab root stays unrotated. static readonly string[] LegacyMinusZ = { "fence", "stall", "flowerpot", "rocks", "steps", "cypress", "bush" }; static string Repo => Directory.GetParent(Application.dataPath).Parent.FullName; static string Review => Path.Combine(Repo, "Tools/output/homecamp_review"); [Serializable] class Receipt { public string status = ""; public int triangles = 0; public int targetTriangles = 0; } public static string Inspect() { return string.Join("\n", Enumerable.Range(0, SceneManager.sceneCount).Select(i => { var s = SceneManager.GetSceneAt(i); return s.path + " dirty=" + s.isDirty; })); } public static string RenderInfo() { var a = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset; if (!a) return "No URP asset"; return QualitySettings.names[QualitySettings.GetQualityLevel()] + " asset=" + AssetDatabase.GetAssetPath(a) + " mainShadows=" + a.supportsMainLightShadows + " distance=" + a.shadowDistance + " res=" + a.mainLightShadowmapResolution + " cascades=" + a.shadowCascadeCount + " soft=" + a.supportsSoftShadows + " hdr=" + a.supportsHDR + " msaa=" + a.msaaSampleCount; } static void Folder(string path) { if (AssetDatabase.IsValidFolder(path)) return; var parent = path.Substring(0, path.LastIndexOf('/')); Folder(parent); AssetDatabase.CreateFolder(parent, Path.GetFileName(path)); } static Bounds BoundsOf(GameObject go) { var renderers = go.GetComponentsInChildren(true); if (renderers.Length == 0) throw new InvalidOperationException("No renderer: " + go.name); var b = renderers[0].bounds; foreach (var r in renderers.Skip(1)) b.Encapsulate(r.bounds); return b; } static void SetLayer(GameObject go) { foreach (var t in go.GetComponentsInChildren(true)) t.gameObject.layer = Layer; } static void Source(string source, string destination) { if (!File.Exists(source)) throw new FileNotFoundException(source); Folder(Path.GetDirectoryName(destination).Replace('\\', '/')); File.Copy(source, destination, true); AssetDatabase.ImportAsset(destination, ImportAssetOptions.ForceSynchronousImport); } static void TextureSettings(string path, bool srgb, bool backdrop = false, bool tile = false, bool isProp = false) { var importer = (TextureImporter)AssetImporter.GetAtPath(path); importer.textureType = TextureImporterType.Default; importer.sRGBTexture = srgb; importer.alphaIsTransparency = false; importer.mipmapEnabled = !backdrop; importer.maxTextureSize = isProp ? 512 : (tile ? 1024 : 2048); importer.textureCompression = (tile || isProp) ? TextureImporterCompression.Compressed : TextureImporterCompression.Uncompressed; importer.wrapMode = tile ? TextureWrapMode.Repeat : TextureWrapMode.Clamp; importer.anisoLevel = tile ? 4 : 1; importer.SaveAndReimport(); } static Material SaveMaterial(string path, string shaderName) { var shader = Shader.Find(shaderName); if (!shader) throw new InvalidOperationException("Missing shader " + shaderName); var mat = AssetDatabase.LoadAssetAtPath(path); if (!mat) { mat = new Material(shader); AssetDatabase.CreateAsset(mat, path); } mat.shader = shader; return mat; } public static string BuildAssets() { if (EditorApplication.isPlaying) throw new InvalidOperationException("Stop Play before authoring."); AssetDatabase.ImportAsset("Assets/Bundles/Shaders/XShader/HomeCampXPbr.shader", ImportAssetOptions.ForceSynchronousImport); // Validate all four receipts before importing any model. foreach (var part in Parts) { var path = Path.Combine(Repo, "Tools/output/homecamp_studio", part, "production.json"); var receipt = JsonUtility.FromJson(File.ReadAllText(path)); if (receipt.status != "ready_for_unity" || receipt.triangles * 10 > receipt.targetTriangles * 13) throw new InvalidOperationException("Unfinished model " + part); } foreach (var part in Parts) ImportPart(part); Source(Path.Combine(Repo, "Doc/Design/Scene/HomeCamp/backdrop.png"), Root + "/backdrop.png"); TextureSettings(Root + "/backdrop.png", true, true); var background = SaveMaterial(Root + "/backdrop.mat", "Universal Render Pipeline/Unlit"); background.SetTexture("_BaseMap", AssetDatabase.LoadAssetAtPath(Root + "/backdrop.png")); background.SetColor("_BaseColor", Color.white); EditorUtility.SetDirty(background); AssetDatabase.SaveAssetIfDirty(background); BuildLookDev(); return "Imported four Studio assets + XPbr, authored four component Prefabs, unlit backdrop and lit look-dev materials."; } /// Imports the v2 B/C props whose five-step receipts are ready; returns the imported ids. public static string BuildProps() { if (EditorApplication.isPlaying) throw new InvalidOperationException("Stop Play before authoring."); AssetDatabase.ImportAsset("Assets/Bundles/Shaders/XShader/HomeCampXPbr.shader", ImportAssetOptions.ForceSynchronousImport); var done = new List(); var skipped = new List(); foreach (var part in Props) { var path = Path.Combine(Repo, "Tools/output/homecamp_studio", part, "production.json"); var receipt = File.Exists(path) ? JsonUtility.FromJson(File.ReadAllText(path)) : null; if (receipt == null || receipt.status != "ready_for_unity" || receipt.triangles * 10 > receipt.targetTriangles * 13) { skipped.Add(part); continue; } ImportPart(part); done.Add(part); } return "Imported props: " + string.Join(",", done) + "; not ready: " + string.Join(",", skipped); } /// Reimports cottage when its five-step receipt is ready (v2 awning side house). public static string BuildCottage() { if (EditorApplication.isPlaying) throw new InvalidOperationException("Stop Play before authoring."); AssetDatabase.ImportAsset("Assets/Bundles/Shaders/XShader/HomeCampXPbr.shader", ImportAssetOptions.ForceSynchronousImport); var path = Path.Combine(Repo, "Tools/output/homecamp_studio/cottage/production.json"); var receipt = File.Exists(path) ? JsonUtility.FromJson(File.ReadAllText(path)) : null; if (receipt == null || receipt.status != "ready_for_unity" || receipt.triangles * 10 > receipt.targetTriangles * 13) throw new InvalidOperationException("Cottage not ready_for_unity"); ImportPart("cottage"); // Secondary UV for lightmaps (same as mainhouse/fountain). var importer = (ModelImporter)AssetImporter.GetAtPath(Root + "/cottage/cottage.fbx"); if (!importer.generateSecondaryUV || importer.secondaryUVPackMargin < 8) { importer.generateSecondaryUV = true; importer.secondaryUVPackMargin = 8; importer.SaveAndReimport(); } return "Imported cottage triangles=" + receipt.triangles; } static void ImportPart(string part) { var dir = Root + "/" + part; var source = Path.Combine(Repo, "Tools/output/homecamp_studio", part, "xpbr"); foreach (var suffix in new[] { ".fbx", "_base.png", "_mix.png" }) Source(Path.Combine(source, part + suffix), dir + "/" + part + suffix); var fbx = dir + "/" + part + ".fbx"; var importer = (ModelImporter)AssetImporter.GetAtPath(fbx); importer.globalScale = 1; importer.useFileScale = false; importer.bakeAxisConversion = true; importer.materialImportMode = ModelImporterMaterialImportMode.None; importer.meshCompression = ModelImporterMeshCompression.Off; importer.isReadable = part == "ground"; importer.addCollider = false; importer.SaveAndReimport(); bool isProp = Props.Contains(part); TextureSettings(dir + "/" + part + "_base.png", true, isProp: isProp); TextureSettings(dir + "/" + part + "_mix.png", false, isProp: isProp); var mat = SaveMaterial(dir + "/" + part + ".mat", "Test/HomeCampXPbr"); mat.SetTexture("_MainTex", AssetDatabase.LoadAssetAtPath(dir + "/" + part + "_base.png")); mat.SetTexture("_MixTex", AssetDatabase.LoadAssetAtPath(dir + "/" + part + "_mix.png")); // Candy plaster: keep albedo readable, kill hot specular (fountain bake A~0.2 → smoothness 0.8 without scale). mat.SetColor("_BaseColor", new Color(1.08f, 1.06f, 1.03f, 1)); mat.SetFloat("_Metallic", .05f); mat.SetFloat("_Roughness", 1); mat.SetFloat("_SmoothnessScale", .22f); mat.SetFloat("_AO", .65f); mat.SetFloat("_LightmapShadeGamma", .58f); mat.enableInstancing = true; EditorUtility.SetDirty(mat); AssetDatabase.SaveAssetIfDirty(mat); var scene = EditorSceneManager.NewPreviewScene(); try { var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath(fbx), scene); go.name = part; go.transform.localPosition = Vector3.zero; go.transform.localRotation = Quaternion.identity; go.transform.localScale = Vector3.one; foreach (var r in go.GetComponentsInChildren()) r.sharedMaterial = mat; if (part == "ground") foreach (var f in go.GetComponentsInChildren()) { var c = f.gameObject.AddComponent(); c.sharedMesh = f.sharedMesh; c.convex = false; } var root = go; if (Props.Contains(part)) { root = new GameObject(part); SceneManager.MoveGameObjectToScene(root, scene); go.transform.SetParent(root.transform, false); go.name = part + "_mesh"; if (LegacyMinusZ.Contains(part)) go.transform.localRotation = Quaternion.Euler(0, 180, 0); } PrefabUtility.SaveAsPrefabAsset(root, dir + "/" + part + ".prefab"); } finally { EditorSceneManager.ClosePreviewScene(scene); } } /// Lit foliage cards, tiled XTerrain ground and post Volume; does not reimport Studio models. public static string BuildLookDev() { if (EditorApplication.isPlaying) throw new InvalidOperationException("Stop Play before authoring."); AssetDatabase.ImportAsset("Assets/Bundles/Shaders/XShader/HomeCampXPbr.shader", ImportAssetOptions.ForceSynchronousImport); AssetDatabase.ImportAsset("Assets/Bundles/Shaders/XShader/XTerrain.shader", ImportAssetOptions.ForceSynchronousImport); BuildCardMaterials(); BuildGroundSurface(); BuildVolumeProfile(); BuildLightingSettings(); TuneXPbrMaterials(); // Lightmap UVs go to UV1 only; Studio UV0 and baked XPbr textures stay untouched. foreach (var part in new[] { "mainhouse", "fountain", "cottage" }) { var importer = (ModelImporter)AssetImporter.GetAtPath(Root + "/" + part + "/" + part + ".fbx"); if (!importer) continue; if (importer.generateSecondaryUV && importer.secondaryUVPackMargin >= 8) continue; importer.generateSecondaryUV = true; importer.secondaryUVPackMargin = 8; importer.SaveAndReimport(); } return "Authored lit foliage cards, XTerrain ground, HomeCampVolume, lighting settings and lightmap UVs."; } /// Matte candy response: lower metallic/smoothness, slight albedo lift for readable diffuse. static void TuneXPbrMaterials() { foreach (var path in AssetDatabase.FindAssets("t:Material", new[] { Root }) .Select(AssetDatabase.GUIDToAssetPath) .Where(p => p.EndsWith(".mat"))) { var mat = AssetDatabase.LoadAssetAtPath(path); if (!mat || mat.shader == null || mat.shader.name != "Test/HomeCampXPbr") continue; mat.SetColor("_BaseColor", new Color(1.08f, 1.06f, 1.03f, 1)); mat.SetFloat("_Metallic", .05f); mat.SetFloat("_Roughness", 1); mat.SetFloat("_SmoothnessScale", .22f); mat.SetFloat("_AO", .65f); mat.SetFloat("_LightmapShadeGamma", .58f); mat.enableInstancing = true; EditorUtility.SetDirty(mat); AssetDatabase.SaveAssetIfDirty(mat); } } static LightingSettings BuildLightingSettings() { var path = Root + "/HomeCampLighting.lighting"; var settings = AssetDatabase.LoadAssetAtPath(path); if (!settings) { settings = new LightingSettings { name = "HomeCampLighting" }; AssetDatabase.CreateAsset(settings, path); } settings.bakedGI = true; settings.realtimeGI = false; settings.lightmapper = LightingSettings.Lightmapper.ProgressiveGPU; // Fully-baked sun stores cast shadows in lightmap RGB (offscreen camera.Render has no realtime shadowmaps). settings.mixedBakeMode = MixedLightingMode.Subtractive; settings.lightmapResolution = 16; settings.lightmapPadding = 4; settings.lightmapMaxSize = 1024; settings.directionalityMode = LightmapsMode.NonDirectional; // Enough bounces for readable shade walls; keep indirect modest so plaza cast shadows stay dark. settings.directSampleCount = 48; settings.indirectSampleCount = 512; settings.environmentSampleCount = 128; settings.maxBounces = 3; settings.ao = true; settings.aoMaxDistance = .75f; settings.aoExponentDirect = .55f; settings.aoExponentIndirect = .7f; settings.albedoBoost = 1f; settings.indirectScale = 1f; settings.lightmapCompression = LightmapCompression.NormalQuality; settings.filteringMode = LightingSettings.FilterMode.Auto; EditorUtility.SetDirty(settings); AssetDatabase.SaveAssetIfDirty(settings); return settings; } static void BuildCardMaterials() { foreach (var name in new[] { "tree_card", "flower_card" }) { Source(Path.Combine(Repo, "Doc/Design/Scene/HomeCamp", name + ".png"), Root + "/" + name + ".png"); TextureSettings(Root + "/" + name + ".png", true); var ti = (TextureImporter)AssetImporter.GetAtPath(Root + "/" + name + ".png"); ti.alphaIsTransparency = true; ti.SaveAndReimport(); var card = SaveMaterial(Root + "/" + name + ".mat", "Universal Render Pipeline/Simple Lit"); var texture = AssetDatabase.LoadAssetAtPath(Root + "/" + name + ".png"); // The lightmapper reads cutout transparency from _MainTex/_Cutoff. card.SetTexture("_BaseMap", texture); card.SetTexture("_MainTex", texture); card.SetColor("_BaseColor", Color.white); card.SetFloat("_AlphaClip", 1); card.SetFloat("_Cutoff", .55f); card.EnableKeyword("_ALPHATEST_ON"); card.SetFloat("_Cull", 0); card.renderQueue = 2450; card.SetFloat("_ReceiveShadows", 1); card.DisableKeyword("_RECEIVE_SHADOWS_OFF"); card.SetFloat("_SpecularHighlights", 0); card.DisableKeyword("_SPECULAR_COLOR"); card.SetOverrideTag("RenderType", "TransparentCutout"); EditorUtility.SetDirty(card); AssetDatabase.SaveAssetIfDirty(card); } } static Texture2D GroundLayer(string name, bool srgb, bool tile) { Source(Path.Combine(Repo, "Doc/Design/Scene/HomeCamp", name + ".png"), Root + "/" + name + ".png"); TextureSettings(Root + "/" + name + ".png", srgb, false, tile); return AssetDatabase.LoadAssetAtPath(Root + "/" + name + ".png"); } static void BuildGroundSurface() { const float half = GroundHalfSize; var stone = GroundLayer("ground_tile_stone", true, true); var grass = GroundLayer("ground_tile_grass", true, true); var grassDark = GroundLayer("ground_tile_grass_dark", true, true); var control = GroundLayer("ground_control", false, false); var mat = SaveMaterial(Root + "/ground_terrain.mat", "XWorld/XTerrain"); mat.SetTexture("_LayerTex0", stone); mat.SetTexture("_LayerTex1", grass); mat.SetTexture("_LayerTex2", grassDark); mat.SetTexture("_LayerTex3", grassDark); mat.SetTexture("_Control", control); mat.SetFloat("_Tiling", half * 2 / StoneTileMeters); // Softer micro-relief so the lawn reads as soft grass rather than shiny plastic. // High _Roughness bias → nearly matte ground; metallic off so stone/grass stay diffuse-led. mat.SetFloat("_BumpScale", 1.6f); mat.SetFloat("_Roughness", 1.15f); mat.SetFloat("_Metallic", 0); mat.SetFloat("_AO", .9f); // Less GI lift → ~20% deeper shade on busy ground albedo. mat.SetFloat("_LightmapContrast", .58f); mat.SetFloat("_LightmapShadeMul", 1f); mat.SetColor("_LightmapShadeTint", new Color(.88f, .9f, .95f, 1f)); EditorUtility.SetDirty(mat); AssetDatabase.SaveAssetIfDirty(mat); var mesh = AssetDatabase.LoadAssetAtPath(Root + "/GroundSurface.asset"); if (!mesh) { mesh = new Mesh(); AssetDatabase.CreateAsset(mesh, Root + "/GroundSurface.asset"); } mesh.Clear(); mesh.name = "HomeCamp continuous ground surface"; mesh.vertices = new[] {new Vector3(-half,0,-half), new Vector3(-half,0,half), new Vector3(half,0,half), new Vector3(half,0,-half)}; mesh.uv = new[] {new Vector2(0,0),new Vector2(0,1),new Vector2(1,1),new Vector2(1,0)}; mesh.uv2 = mesh.uv; mesh.triangles = new[] {0,1,2,0,2,3}; mesh.RecalculateNormals(); mesh.RecalculateTangents(); mesh.RecalculateBounds(); EditorUtility.SetDirty(mesh); AssetDatabase.SaveAssetIfDirty(mesh); var scene = EditorSceneManager.NewPreviewScene(); try { var go = new GameObject("GroundSurface"); SceneManager.MoveGameObjectToScene(go, scene); go.AddComponent().sharedMesh = mesh; var renderer = go.AddComponent(); renderer.sharedMaterial = mat; renderer.shadowCastingMode = ShadowCastingMode.Off; renderer.receiveShadows = true; go.AddComponent().sharedMesh = mesh; PrefabUtility.SaveAsPrefabAsset(go, Root + "/GroundSurface.prefab"); } finally { EditorSceneManager.ClosePreviewScene(scene); } } static VolumeProfile BuildVolumeProfile() { var path = Root + "/HomeCampVolume.asset"; var profile = AssetDatabase.LoadAssetAtPath(path); if (!profile) { profile = ScriptableObject.CreateInstance(); AssetDatabase.CreateAsset(profile, path); } foreach (var old in profile.components.ToArray()) { profile.components.Remove(old); Object.DestroyImmediate(old, true); } T Add() where T : VolumeComponent { var c = profile.Add(true); c.name = typeof(T).Name; c.hideFlags = HideFlags.HideInInspector | HideFlags.HideInHierarchy; AssetDatabase.AddObjectToAsset(c, profile); return c; } Add().mode.value = TonemappingMode.Neutral; var color = Add(); // Keep the sunny palette while retaining roof and paving highlights in the home screen. color.postExposure.value = .30f; color.contrast.value = 10; color.saturation.value = 5; color.hueShift.value = 0; color.colorFilter.value = Color.white; var white = Add(); white.temperature.value = 6; white.tint.value = 0; var bloom = Add(); bloom.threshold.value = 1.15f; bloom.intensity.value = .12f; bloom.scatter.value = .55f; var vignette = Add(); vignette.intensity.value = .18f; vignette.smoothness.value = .45f; EditorUtility.SetDirty(profile); AssetDatabase.SaveAssetIfDirty(profile); return profile; } static GameObject Place(Transform parent, string path, string name, Vector3 pos, float size, bool width = false, bool faceCenter = false) { var prefab = AssetDatabase.LoadAssetAtPath(path); if (!prefab) throw new FileNotFoundException(path); var go = (GameObject)PrefabUtility.InstantiatePrefab(prefab, parent); go.name = name; go.transform.localPosition = Vector3.zero; go.transform.localRotation = Quaternion.identity; go.transform.localScale = Vector3.one; var b = BoundsOf(go); var current = width ? Mathf.Max(b.size.x, b.size.z) : b.size.y; go.transform.localScale = Vector3.one * (size / current); if (faceCenter) { var away = new Vector3(pos.x, 0, pos.z - .8f); go.transform.localRotation = Quaternion.LookRotation(away, Vector3.up); } go.transform.position = pos; SetLayer(go); return go; } static string PartPath(string part) => Root + "/" + part + "/" + part + ".prefab"; static string ReusePath(string part) { if (part == "tree" || part == "bush") { var name = part == "bush" ? "shrub" : "tree"; return "Assets/Bundles/Scenes/Prototype/Battlefield/A/Components/" + name + "/" + name + ".prefab"; } return Shared + "/" + part + "/" + part + ".prefab"; } static float GroundY(GameObject ground, float x, float z) { var ray = new Ray(new Vector3(x, 60, z), Vector3.down); var hits = ground.GetComponentsInChildren().Select(c => { RaycastHit hit; return c.Raycast(ray, out hit, 120) ? hit.point.y : float.NegativeInfinity; }).ToArray(); var y = hits.Max(); if (float.IsNegativeInfinity(y)) throw new InvalidOperationException("Ground ray missed at " + x + "," + z); return y; } static GameObject FoliageCard(Transform parent, string name, string image, Vector3 bottom, float height) { var go = GameObject.CreatePrimitive(PrimitiveType.Quad); go.name = name; go.transform.SetParent(parent, false); Object.DestroyImmediate(go.GetComponent()); var texture = AssetDatabase.LoadAssetAtPath(Root + "/" + image + ".png"); var rotation = Quaternion.LookRotation(CamTarget - CamPos, Vector3.up); go.transform.rotation = rotation; go.transform.position = bottom + (rotation * Vector3.up) * height * .5f; go.transform.localScale = new Vector3(height * texture.width / texture.height, height, 1); var renderer = go.GetComponent(); renderer.sharedMaterial = AssetDatabase.LoadAssetAtPath(Root + "/" + image + ".mat"); // Trees cast into the lightmap again (concept art needs canopy shadows); kept behind roofs to avoid plaza ink. renderer.shadowCastingMode = ShadowCastingMode.On; renderer.receiveShadows = true; SetLayer(go); return go; } static readonly Vector3 CamPos = new Vector3(0, 14, -25), CamTarget = new Vector3(0, 1.6f, 3); const float CamFov = 50; static Camera MakeCamera(Scene scene, Transform parent) { var go = new GameObject("HomeCampCamera"); SceneManager.MoveGameObjectToScene(go, scene); if (parent) go.transform.SetParent(parent, false); var camera = go.AddComponent(); go.transform.position = CamPos; go.transform.LookAt(CamTarget); camera.fieldOfView = CamFov; camera.nearClipPlane = .1f; camera.farClipPlane = 180; camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = new Color(.46f, .72f, .88f); camera.cullingMask = 1 << Layer; camera.scene = scene; camera.allowHDR = true; camera.allowMSAA = true; var data = camera.GetUniversalAdditionalCameraData(); data.renderPostProcessing = true; data.volumeLayerMask = 1 << Layer; data.renderShadows = true; return camera; } static Light Light(Scene scene, Transform parent) { var go = new GameObject("HomeCampSun"); SceneManager.MoveGameObjectToScene(go, scene); if (parent) go.transform.SetParent(parent, false); var light = go.AddComponent(); light.type = LightType.Directional; // Key: warm sun. Fill comes from a separate baked skylight so Subtractive maps do not crush backfaces. // Fully Baked sun: cast shadows live in the lightmap RGB. Mixed/Subtractive still needs // realtime shadow maps, which offscreen camera.Render does not produce — so review looked shadowless. // Noon key: warm sun with realtime soft shadows for buildings, explorer and companions. light.color = new Color(1, .98f, .94f); light.intensity = 1.95f; light.shadows = LightShadows.Soft; light.lightmapBakeType = LightmapBakeType.Realtime; light.shadowAngle = 4; light.shadowStrength = .85f; light.shadowBias = .05f; light.shadowNormalBias = .3f; light.cullingMask = 1 << Layer; go.transform.rotation = Quaternion.Euler(68, 50, 0); BakeFillLight(scene, parent); return light; } /// /// Baked-only opposite skylight (no shadows). URP ignores a second realtime Directional, /// but Progressive still bakes this into lightmaps so backfaces stay cream while cast shadows remain. /// static void BakeFillLight(Scene scene, Transform parent) { var go = new GameObject("HomeCampBakeFill"); SceneManager.MoveGameObjectToScene(go, scene); if (parent) go.transform.SetParent(parent, false); var light = go.AddComponent(); light.type = LightType.Directional; // Noon skylight fill — held back another ~20% so cast shade reads deeper. light.color = new Color(.82f, .88f, 1f); light.intensity = .22f; light.shadows = LightShadows.None; light.lightmapBakeType = LightmapBakeType.Baked; light.cullingMask = 1 << Layer; go.transform.rotation = Quaternion.Euler(75, -30, 0); } /// Grid of probes over the plaza so houses on LightProbes get baked sky fill, not black tetrahedra. static void PlaceLightProbes(Transform parent) { var go = new GameObject("HomeCampLightProbes"); go.transform.SetParent(parent, false); var group = go.AddComponent(); var positions = new System.Collections.Generic.List(); for (int x = -10; x <= 10; x += 4) for (int z = -12; z <= 12; z += 4) for (int y = 1; y <= 5; y += 2) positions.Add(new Vector3(x, y, z)); group.probePositions = positions.ToArray(); } static void Background(Transform parent, Camera camera) { var go = GameObject.CreatePrimitive(PrimitiveType.Quad); go.name = "DistantLakeWaterfallCastle"; go.transform.SetParent(parent, false); Object.DestroyImmediate(go.GetComponent()); var renderer = go.GetComponent(); renderer.sharedMaterial = AssetDatabase.LoadAssetAtPath(Root + "/backdrop.mat"); renderer.shadowCastingMode = ShadowCastingMode.Off; renderer.receiveShadows = false; const float distance = 75; // Wide enough for 3:4 tablets and tall enough for 9:22 ultra-long displays with margin. const float width = 72f; const float height = 56f; const float topHeight = 52f; go.transform.position = camera.transform.position + camera.transform.forward * distance + camera.transform.up * (topHeight - height * .5f); go.transform.rotation = camera.transform.rotation; go.transform.localScale = new Vector3(width, height, 1); SetLayer(go); } public static string Build() { if (EditorApplication.isPlaying) throw new InvalidOperationException("Author in Edit Mode."); var previous = SceneManager.GetActiveScene(); var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive); // An already-open HomeCamp keeps this path. Saving the new scene, then baking the stale one, writes the cards back over the file. var stale = SceneManager.GetSceneByPath(ScenePath); if (stale.IsValid() && stale.isLoaded && stale != scene) { if (previous == stale) previous = default; EditorSceneManager.CloseScene(stale, true); } try { SceneManager.SetActiveScene(scene); var root = new GameObject("HomeCamp"); var ground = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath(Root + "/GroundSurface.prefab"), root.transform); ground.name = "Ground"; SetLayer(ground); // Final source ground must be horizontal; reject a vertical generated plate. var gb = BoundsOf(ground); if (gb.size.y > Mathf.Min(gb.size.x, gb.size.z) * .3f) throw new InvalidOperationException("Ground requires offline orientation correction before assembly: " + gb.size); Physics.SyncTransforms(); var placements = new List(); void Put(string part, string name, float x, float z, float size, bool width = false, bool facing = false, bool reused = false) { var y = GroundY(ground, x, z); if (reused && (part == "tree" || part == "bush")) { FoliageCard(root.transform, name, part == "tree" ? "tree_card" : "flower_card", new Vector3(x,y,z), size); placements.Add(name + " card groundY=" + y.ToString("F3")); return; } var path = reused ? ReusePath(part) : PartPath(part); var go = Place(root.transform, path, name, new Vector3(x, y, z), size, width, facing); var bottom = BoundsOf(go).min.y; go.transform.position += Vector3.up * (y - bottom); placements.Add(name + " groundY=" + y.ToString("F3")); } Put("mainhouse", "MainHouse", 0, 7.2f, 6.5f, false, true); // Left side uses the awning cottage to match the reference market house; right stays a mirrored mainhouse. Put("cottage", "HouseLeft", -6.8f, 3.6f, 3.8f, false, true); Put("mainhouse", "HouseRight", 7.5f, 4, 4, false, true); // Mirrored copy so the reused house does not read as an identical clone; HomeCampXPbr is two-sided. var houseRight = root.transform.Find("HouseRight"); houseRight.localScale = Vector3.Scale(houseRight.localScale, new Vector3(-1, 1, 1)); // Sink house bases a few centimeters to hide the mesh/ground seam seen in portrait review. foreach (var house in new[] { "MainHouse", "HouseLeft", "HouseRight" }) root.transform.Find(house).position += Vector3.down * .08f; Put("fountain", "Fountain", 0, .8f, 4.4f, true); // Place protagonist Explorer in the central plaza (scaled 4x for isometric camp readability) var explorerPrefab = AssetDatabase.LoadAssetAtPath("Assets/Bundles/Characters/Human/Explorer/Explorer.prefab"); if (explorerPrefab != null) { var explorerGo = (GameObject)PrefabUtility.InstantiatePrefab(explorerPrefab, root.transform); explorerGo.name = "Explorer"; float expY = GroundY(ground, 0, -3.5f); explorerGo.transform.position = new Vector3(0, expY, -3.5f); explorerGo.transform.localScale = Vector3.one * 4.0f; // Face towards camera / player by default explorerGo.transform.rotation = Quaternion.Euler(0, 180f, 0); SetLayer(explorerGo); placements.Add("Explorer protagonist groundY=" + expY.ToString("F3")); } // Place wandering companions in the camp (scaled 4x for isometric readability) var petDefs = new[] { (path: "Assets/Bundles/Characters/Animal/FireCat/FireCat_Companion.prefab", name: "Pet_FireCat", x: -3.8f, z: 1.2f, r: 2.8f, s: 3.40f), (path: "Assets/Bundles/Characters/Animal/RockBallStudio/RockBallStudio_Companion.prefab", name: "Pet_RockBall", x: 2.6f, z: -1.8f, r: 2.8f, s: 3.00f), (path: "Assets/Bundles/Characters/Animal/BudBud/BudBud_Companion.prefab", name: "Pet_BudBud", x: -1.6f, z: 4.2f, r: 2.6f, s: 3.20f) }; var petWandererType = AppDomain.CurrentDomain.GetAssemblies() .Select(a => a.GetType("HotUpdate.Gameplay.CampPetWanderer")) .FirstOrDefault(t => t != null); foreach (var petDef in petDefs) { var petPrefab = AssetDatabase.LoadAssetAtPath(petDef.path); if (petPrefab == null) continue; var petGo = (GameObject)PrefabUtility.InstantiatePrefab(petPrefab, root.transform); petGo.name = petDef.name; float petY = GroundY(ground, petDef.x, petDef.z); petGo.transform.position = new Vector3(petDef.x, petY, petDef.z); petGo.transform.localScale = Vector3.one * petDef.s; // Default facing towards camera for -Z models petGo.transform.rotation = Quaternion.Euler(0, 0, 0); SetLayer(petGo); var col = petGo.GetComponent(); if (col == null) { col = petGo.AddComponent(); col.center = new Vector3(0, 0.45f, 0); col.radius = 0.4f; col.height = 1.0f; } if (petWandererType != null) { var wanderer = petGo.GetComponent(petWandererType); if (wanderer == null) wanderer = petGo.AddComponent(petWandererType); var centerField = petWandererType.GetField("wanderCenter"); if (centerField != null) centerField.SetValue(wanderer, new Vector3(petDef.x, petY, petDef.z)); var radiusField = petWandererType.GetField("wanderRadius"); if (radiusField != null) radiusField.SetValue(wanderer, petDef.r); } placements.Add($"{petDef.name} groundY={petY:F3}"); } // Keep canopy trees behind the roofs so they frame the backdrop without dunking HouseLeft in shadow. // Nearer canopy slots are placed after the viewer vector, as DGX meshes when ready. // TreeRearLeft / TreeRearRight stay cards: they sit behind the roofs and another 2000 tris each is not worth it. Put("tree", "TreeRearLeft", -5.6f, 14.5f, 4.6f, false, false, true); Put("tree", "TreeRearRight", 5.6f, 14.5f, 4.6f, false, false, true); Put("lamp", "LampLeft", -5.2f, -.5f, 2.6f, false, false, true); Put("lamp", "LampRight", 5.2f, 1, 2.6f, false, false, true); Put("lamp", "LampForeground", 5.5f, -6, 2.7f, false, false, true); Put("lamp", "LampPath", 3.4f, -8.4f, 2.7f, false, false, true); Put("bench", "BenchFrontLeft", -4.4f, -4, 2.2f, true, true, true); Put("bench", "BenchRight", 6.8f, -2.2f, 2.2f, true, true, true); // Fountain ring is placed after the viewer vector: six flowercluster meshes, or the old cards if the prefab is missing. foreach (var p in new[] { new Vector2(-7,-5), new Vector2(-5,-7), new Vector2(6,-6), new Vector2(8,-3), new Vector2(-9,2), new Vector2(9,2) }) Put("bush", "BorderFlowers", p.x, p.y, .9f, false, false, true); // Flower clusters along the plaza rim and the path so the stone/grass edge is never a bare cut. var rim = 0; for (int i = 0; i < 18; i++) { var angle = (-80 + i * 20f) * Mathf.Deg2Rad; var x = Mathf.Sin(angle) * 9.3f; var z = 3.5f + Mathf.Cos(angle) * 6.9f; if (z < -2 && Mathf.Abs(x) < 2.5f) continue; Put("bush", "RimFlowers" + rim++, x, z, .7f + (i % 3) * .12f, false, false, true); } var pathFlowers = new[] { new Vector2(-.6f,-7.5f), new Vector2(.3f,-12.5f), new Vector2(1.3f,-16), new Vector2(4.6f,-15.5f), new Vector2(-3.2f,-14.2f), new Vector2(3.2f,-14.8f), new Vector2(5.1f,-10.2f), new Vector2(.8f,-18.5f) }; for (int i = 0; i < pathFlowers.Length; i++) Put("bush", "PathFlowers" + i, pathFlowers[i].x, pathFlowers[i].y, .65f + (i % 3) * .08f, false, false, true); void Prop(string part, string name, float x, float z, float size, bool width, Vector3 face, bool presentToCamera = false) { if (!AssetDatabase.LoadAssetAtPath(PartPath(part))) { placements.Add(name + " skipped (no prefab)"); return; } var y = GroundY(ground, x, z); var go = Place(root.transform, PartPath(part), name, new Vector3(x, y, z), size, width); if (presentToCamera) go.transform.rotation = Quaternion.LookRotation(CamTarget - CamPos, Vector3.up); else { var toward = face - new Vector3(x, face.y, z); go.transform.rotation = Quaternion.LookRotation(toward, Vector3.up); } go.transform.position += Vector3.up * (y - BoundsOf(go).min.y); placements.Add(name + " groundY=" + y.ToString("F3")); } var plaza = new Vector3(0, 0, 2); var viewer = new Vector3(CamPos.x, 0, CamPos.z); // Portrait horizontal view is about 29 degrees. Card slots at |x|>=11 fall outside it, // so the shade trees stand in the lawn beside the roofs instead of clipping the frame edge. if (AssetDatabase.LoadAssetAtPath(PartPath("shadetree"))) { Prop("shadetree", "TreeBackLeft", -5.6f, 10.4f, 6.2f, false, viewer); Prop("shadetree", "TreeBackRight", 6.2f, 9.6f, 5.7f, false, plaza); } else { Put("tree", "TreeBackLeft", -5.6f, 10.4f, 6.2f, false, false, true); Put("tree", "TreeBackRight", 6.2f, 9.6f, 5.7f, false, false, true); } foreach (var (n, x, z, s) in new[] { ("ClusterNearL", -2.4f, -11.2f, .62f), ("ClusterNearR", 3.6f, -12.2f, .7f), ("ClusterPathL", -4.6f, -8.6f, .55f), ("ClusterPlaza", 2.6f, -6.4f, .5f) }) Prop("flowercluster", n, x, z, s, false, viewer); if (AssetDatabase.LoadAssetAtPath(PartPath("flowercluster"))) { // Blossoms are on local +Z. Yaw-only leaves the leafy top toward this high camera, so present the front. for (int i = 0; i < 6; i++) { var angle = (i * 60f) * Mathf.Deg2Rad; var size = .62f + (i % 3) * .06f; Prop("flowercluster", "FountainCluster" + i, Mathf.Sin(angle) * 3.25f, .8f + Mathf.Cos(angle) * 3.25f, size, false, viewer, true); } } else { for (int i = 0; i < 12; i++) { var angle = (i * 30f) * Mathf.Deg2Rad; Put("bush", "FountainFlowers" + i, Mathf.Sin(angle) * 2.7f, .8f + Mathf.Cos(angle) * 2.7f, .55f, false, false, true); } } // StallLeft removed: left cottage already carries the yellow awning counter. Prop("stall", "StallRight", 4.5f, 2.8f, 2.4f, false, plaza); Prop("steps", "StepsRear", 4.3f, 10.2f, 2.6f, true, viewer); Prop("cypress", "CypressLeft", -4.3f, 9.8f, 5, false, viewer); Prop("cypress", "CypressRight", 7.8f, 8.6f, 5.6f, false, viewer); Prop("cypress", "CypressFrontLeft", -6.8f, -3.4f, 4.6f, false, viewer); // Portrait frustum is only about +-5.4m wide at z=-10, so foreground props stay inside it. foreach (var (n, x, z) in new[] { ("FenceLeftA", -5.2f, -8.6f), ("FenceLeftB", -3.4f, -11), ("FenceRightA", 5.4f, -9.6f) }) Prop("fence", n, x, z, 2.8f, true, plaza); // Single-pot prefab scaled into small clusters (three-pot Studio mesh failed UV-preserving reduction). foreach (var (n, x, z, s) in new[] { ("PotMainL1", -2.9f, 4.8f, .7f), ("PotMainL2", -2.4f, 5.1f, .55f), ("PotMainL3", -2.55f, 4.45f, .48f), ("PotMainR1", 2.9f, 4.9f, .7f), ("PotMainR2", 3.35f, 5.15f, .55f), ("PotMainR3", 3.1f, 4.5f, .48f), ("PotLeft1", -6.5f, 1.4f, .65f), ("PotLeft2", -6.05f, 1.7f, .5f), ("PotRight1", 6.7f, 1.8f, .65f), ("PotRight2", 7.1f, 1.5f, .5f) }) Prop("flowerpot", n, x, z, s, false, viewer); foreach (var (n, x, z, s) in new[] { ("RockFrontLeft", -1.8f, -13.6f, 1.6f), ("RockFrontRight", 4.6f, -13.4f, 1.3f), ("RockLeft", -6, -6.4f, 1.2f), ("RockNear", -.4f, -15.8f, 1.1f), ("RockPath", 2.8f, -11.2f, .95f) }) Prop("rocks", n, x, z, s, true, viewer); foreach (var (n, x, z, s) in new[] { ("BushFrontLeft", -4.6f, -12.8f, 1.5f), ("BushFront", -1.4f, -9.6f, 1.1f), ("BushFrontRight", 5.6f, -12.2f, 1.3f), ("BushRight", 6.4f, -6.2f, 1.3f), ("BushLeft", -7.2f, -1.6f, 1.4f), ("BushRearLeft", -5.6f, 7.8f, 1.2f), ("BushRearRight", 6, 8, 1.2f), ("BushNearLeft", -3.6f, -15.2f, 1.25f), ("BushNearRight", 3.8f, -15.6f, 1.15f), ("BushMidLeft", -5.4f, -7.4f, 1.2f) }) Prop("bush", n, x, z, s, true, viewer); PlaceLightProbes(root.transform); var camera = MakeCamera(scene, root.transform); var sun = Light(scene, root.transform); Background(root.transform, camera); var volume = new GameObject("PostVolume").AddComponent(); volume.transform.SetParent(root.transform, false); volume.isGlobal = true; volume.priority = 10; volume.sharedProfile = AssetDatabase.LoadAssetAtPath(Root + "/HomeCampVolume.asset"); if (!volume.sharedProfile) throw new InvalidOperationException("Run BuildLookDev before Build."); SetLayer(root); RenderSettings.sun = sun; RenderSettings.ambientMode = AmbientMode.Trilight; // Noon ambient ~+30% vs prior; still cooler in shade so ground cast shadows stay visible. RenderSettings.ambientIntensity = .9f; RenderSettings.ambientSkyColor = new Color(.85f, .9f, .98f); RenderSettings.ambientEquatorColor = new Color(.72f, .7f, .64f); RenderSettings.ambientGroundColor = new Color(.55f, .52f, .46f); RenderSettings.fog = true; RenderSettings.fogMode = FogMode.Linear; RenderSettings.fogColor = new Color(.78f, .88f, .95f); RenderSettings.fogStartDistance = 45; RenderSettings.fogEndDistance = 220; MarkStatic(root); Lightmapping.lightingSettings = AssetDatabase.LoadAssetAtPath(Root + "/HomeCampLighting.lighting"); if (!Lightmapping.lightingSettings) throw new InvalidOperationException("Run BuildLookDev before Build."); // Subtractive fills shadowed texels with this color; keep it close to sunlit grass so maps stay soft. // Darker than lit plaster so cast shadows read, brighter than ink so walls stay detailed. RenderSettings.subtractiveShadowColor = new Color(.38f, .44f, .55f); PrefabUtility.SaveAsPrefabAsset(root, PrefabPath); Directory.CreateDirectory(Review); File.WriteAllText(Path.Combine(Review, "assembly.txt"), string.Join("\n", placements)); // Review-only 1.6m stand-in; kept in the scene outside the Prefab, disabled for art captures. ScaleReference(ground).SetActive(false); EditorSceneManager.SaveScene(scene, ScenePath); return "Saved " + PrefabPath + " and " + ScenePath + "; " + placements.Count + " placements. Run Bake next."; } finally { if (previous.IsValid() && previous.isLoaded) SceneManager.SetActiveScene(previous); EditorSceneManager.CloseScene(scene, true); } } // Ground + buildings lightmapped so Subtractive bake stores visible cast shadows for review captures. static readonly string[] Lightmapped = { "Ground", "MainHouse", "HouseLeft", "HouseRight", "Fountain" }; static void MarkStatic(GameObject root) { foreach (var renderer in root.GetComponentsInChildren(true)) { var top = renderer.transform; while (top.parent && top.parent != root.transform) top = top.parent; var name = top.name; if (name == "DistantLakeWaterfallCastle" || name.Contains("Flowers")) continue; GameObjectUtility.SetStaticEditorFlags(renderer.gameObject, StaticEditorFlags.ContributeGI | StaticEditorFlags.BatchingStatic); // Lamps, benches and tree cards cast into the lightmap but are lit from probes, so shared assets need no lightmap UVs. if (renderer is MeshRenderer mesh) mesh.receiveGI = Lightmapped.Contains(name) ? ReceiveGI.Lightmaps : ReceiveGI.LightProbes; } } /// Bakes HomeCamp.unity alone, renders review captures, then restores the previously open scenes. public static async System.Threading.Tasks.Task Bake() { if (EditorApplication.isPlaying) throw new InvalidOperationException("Stop Play before baking."); if (Enumerable.Range(0, SceneManager.sceneCount).Any(i => SceneManager.GetSceneAt(i).isDirty)) throw new InvalidOperationException("An open scene has unsaved edits; save or discard it before baking."); var setup = EditorSceneManager.GetSceneManagerSetup(); try { // Bake with HomeCamp as the only open scene so other scenes get no lighting data. // Drop a stale in-memory copy first; OpenScene will not reload a scene that is already loaded. var loaded = SceneManager.GetSceneByPath(ScenePath); if (loaded.IsValid() && loaded.isLoaded) { var holder = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive); EditorSceneManager.CloseScene(loaded, true); EditorSceneManager.CloseScene(holder, true); } EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single); if (!Lightmapping.BakeAsync()) throw new InvalidOperationException("Lightmap bake could not start."); var started = DateTime.UtcNow; while (Lightmapping.isRunning) { if ((DateTime.UtcNow - started).TotalMinutes > 25) { Lightmapping.Cancel(); throw new TimeoutException("Lightmap bake exceeded 25 minutes."); } await System.Threading.Tasks.Task.Delay(500); } // Lightmaps are assigned to the scene a few editor ticks after isRunning clears. for (int i = 0; i < 40 && LightmapSettings.lightmaps.Length == 0; i++) await System.Threading.Tasks.Task.Delay(250); if (LightmapSettings.lightmaps.Length == 0) throw new InvalidOperationException("Bake produced no lightmaps."); EditorSceneManager.SaveScene(SceneManager.GetSceneByPath(ScenePath)); return Capture() + " Bake " + (DateTime.UtcNow - started).TotalSeconds.ToString("F0") + "s."; } finally { if (setup.Length > 0 && !setup.Any(s => s.path == ScenePath && s.isActive && setup.Length == 1)) EditorSceneManager.RestoreSceneManagerSetup(setup); } } public static string Capture() { var previous = SceneManager.GetActiveScene(); var scene = SceneManager.GetSceneByPath(ScenePath); var opened = !scene.IsValid() || !scene.isLoaded; if (opened) scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Additive); try { SceneManager.SetActiveScene(scene); var root = scene.GetRootGameObjects().Single(g => g.name == "HomeCamp"); var camera = root.GetComponentInChildren(); var scale = scene.GetRootGameObjects().Single(g => g.name.StartsWith("ReviewOnly_ScaleReference")); scale.SetActive(true); CaptureCamera(camera, 1080, 1920, "homecamp_scale_reference.png"); scale.SetActive(false); CaptureCamera(camera, 1080, 1920, "homecamp_portrait.png"); CaptureCamera(camera, 1080, 2400, "homecamp_tall.png"); var maps = LightmapSettings.lightmaps.Length; var ground = root.transform.Find("Ground")?.GetComponentInChildren(); var gi = ground ? (" groundLM=" + ground.lightmapIndex + " scaleOffset=" + ground.lightmapScaleOffset) : ""; return "Rendered scale/portrait/tall with " + maps + " lightmap(s)." + gi; } finally { if (previous.IsValid() && previous.isLoaded) SceneManager.SetActiveScene(previous); if (opened) EditorSceneManager.CloseScene(scene, true); } } static GameObject ScaleReference(GameObject ground) { var mat = SaveMaterial(Root + "/review_scale.mat", "Universal Render Pipeline/Lit"); mat.SetColor("_BaseColor", new Color(.55f, .55f, .58f)); EditorUtility.SetDirty(mat); AssetDatabase.SaveAssetIfDirty(mat); var go = GameObject.CreatePrimitive(PrimitiveType.Capsule); go.name = "ReviewOnly_ScaleReference_1.6m"; Object.DestroyImmediate(go.GetComponent()); go.transform.localScale = new Vector3(.45f, .8f, .45f); go.transform.position = new Vector3(2.8f, GroundY(ground, 2.8f, -2.5f) + .8f, -2.5f); go.GetComponent().sharedMaterial = mat; SetLayer(go); return go; } static void CaptureCamera(Camera camera, int width, int height, string name) { Directory.CreateDirectory(Review); var rt = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.ARGB32); var old = RenderTexture.active; var oldTarget = camera.targetTexture; var oldAspect = camera.aspect; var oldAsync = ShaderUtil.allowAsyncCompilation; var texture = new Texture2D(width, height, TextureFormat.RGB24, false); try { ShaderUtil.allowAsyncCompilation = false; camera.aspect = (float)width / height; camera.targetTexture = rt; camera.Render(); camera.Render(); RenderTexture.active = rt; texture.ReadPixels(new Rect(0, 0, width, height), 0, 0); texture.Apply(); File.WriteAllBytes(Path.Combine(Review, name), texture.EncodeToPNG()); } finally { ShaderUtil.allowAsyncCompilation = oldAsync; camera.targetTexture = oldTarget; camera.aspect = oldAspect; RenderTexture.active = old; RenderTexture.ReleaseTemporary(rt); Object.DestroyImmediate(texture); } } public static string CaptureParts() => CaptureSet(Parts); public static string CaptureReuse() => CaptureSet(new[] { "tree", "lamp", "bench", "bush" }); public static string CaptureProps() => CaptureSet(Props.Where(p => AssetDatabase.LoadAssetAtPath(PartPath(p)))); static string CaptureSet(IEnumerable parts) { foreach (var part in parts) { var scene = EditorSceneManager.NewPreviewScene(); try { var path = Parts.Contains(part) || Props.Contains(part) ? PartPath(part) : ReusePath(part); var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath(path), scene); SetLayer(go); Light(scene, null); var b = BoundsOf(go); var extent = Mathf.Max(b.size.x, Mathf.Max(b.size.y, b.size.z)); var camera = MakeCamera(scene, null); camera.orthographic = true; camera.orthographicSize = extent * .7f; camera.nearClipPlane = Mathf.Max(.00001f, extent * .01f); camera.farClipPlane = Mathf.Max(10, extent * 10); foreach (var direction in new[] { "front", "side", "back", "perspective" }) { var offset = direction == "front" ? new Vector3(0,.15f,-3) : direction == "side" ? new Vector3(3,.15f,0) : direction == "back" ? new Vector3(0,.15f,3) : new Vector3(2,1.8f,-3); camera.transform.position = b.center + offset * extent; camera.transform.LookAt(b.center); CaptureCamera(camera, 640, 640, part + "_" + direction + ".png"); } } finally { EditorSceneManager.ClosePreviewScene(scene); } } return "Rendered four views per component."; } public static string Verify() { var report = new List(); foreach (var part in Parts) { var p = AssetDatabase.LoadAssetAtPath(PartPath(part)); if (!p) throw new InvalidOperationException("Missing component " + part); var triangles = p.GetComponentsInChildren().Sum(f => (int)f.sharedMesh.GetIndexCount(0) / 3); var receipt = JsonUtility.FromJson(File.ReadAllText(Path.Combine(Repo, "Tools/output/homecamp_studio", part, "production.json"))); if (triangles > receipt.targetTriangles * 1.3f) throw new InvalidOperationException("Triangle budget " + part); foreach (var r in p.GetComponentsInChildren()) if (!r.sharedMaterial || r.sharedMaterial.shader.name != "Test/HomeCampXPbr") throw new InvalidOperationException("XPbr " + part); report.Add(part + "=" + triangles); } var root = AssetDatabase.LoadAssetAtPath(PrefabPath); if (!root || !File.Exists(ScenePath)) throw new InvalidOperationException("Missing assembled scene/prefab"); if (root.GetComponentsInChildren().Length == 0) throw new InvalidOperationException("Missing ground collider"); var preview = EditorSceneManager.NewPreviewScene(); var triangleCount = 0; try { var instance = (GameObject)PrefabUtility.InstantiatePrefab(root, preview); var ground = instance.transform.Find("Ground").gameObject; Physics.SyncTransforms(); for (int x=-8; x<=8; x+=4) for (int z=-8; z<=8; z+=4) GroundY(ground,x,z); triangleCount = instance.GetComponentsInChildren().Sum(f => (int)f.sharedMesh.GetIndexCount(0)/3); } finally { EditorSceneManager.ClosePreviewScene(preview); } var backdrop = root.transform.Find("DistantLakeWaterfallCastle"); if (!backdrop || backdrop.GetComponent()) throw new InvalidOperationException("Backdrop collider"); var bg = backdrop.GetComponent(); if (bg.sharedMaterial.shader.name != "Universal Render Pipeline/Unlit" || bg.shadowCastingMode != ShadowCastingMode.Off) throw new InvalidOperationException("Backdrop must be unlit without shadows"); if (EditorBuildSettings.scenes.Any(s => s.path == ScenePath)) throw new InvalidOperationException("Review scene unexpectedly added to build"); var groundRenderer = root.transform.Find("Ground").GetComponentInChildren(); if (groundRenderer.sharedMaterial.shader.name != "XWorld/XTerrain" || !groundRenderer.receiveShadows) throw new InvalidOperationException("Ground must be tiled XTerrain receiving shadows"); var unlitGameplay = root.GetComponentsInChildren(true) .Where(r => r.transform != backdrop && r.sharedMaterial && r.sharedMaterial.shader.name.Contains("Unlit")).Select(r => r.name).ToArray(); if (unlitGameplay.Length > 0) throw new InvalidOperationException("Unlit outside backdrop: " + string.Join(",", unlitGameplay)); var volume = root.GetComponentInChildren(); if (!volume || !volume.sharedProfile || !volume.sharedProfile.Has()) throw new InvalidOperationException("Missing post Volume"); var cards = root.GetComponentsInChildren(true).Count(r => r.sharedMaterial && r.sharedMaterial.shader.name == "Universal Render Pipeline/Simple Lit"); Directory.CreateDirectory(Review); var tech = "TECH PASS " + string.Join("; ", report) + "; assembledTriangles=" + triangleCount + "; ground XTerrain lit+shadow; litFoliageCards=" + cards + "; post Volume; fog; ground collision 25/25; unlit quad backdrop only; independent scene; cottage v2 placed as HouseLeft; Studio ground still review-only"; const string art = "ART PENDING six-axis review against home_camp.png; see Tools/output/homecamp_review/compare_portrait.png"; File.WriteAllText(Path.Combine(Review, "verification.txt"), tech + "\n" + art + "\n"); return File.ReadAllText(Path.Combine(Review, "verification.txt")); } public static string OpenForReview() { var scene = SceneManager.GetSceneByPath(ScenePath); if (!scene.IsValid() || !scene.isLoaded) scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Additive); SceneManager.SetActiveScene(scene); var root = scene.GetRootGameObjects().Single(g => g.name == "HomeCamp"); Selection.activeGameObject = root; var camera = root.GetComponentInChildren(); if (SceneView.lastActiveSceneView != null) SceneView.lastActiveSceneView.AlignViewToObject(camera.transform); return "Opened HomeCamp additively; existing scenes preserved."; } public static string CloseReview() { var scene = SceneManager.GetSceneByPath(ScenePath); if (!scene.IsValid() || !scene.isLoaded) return "HomeCamp not open."; if (scene.isDirty) throw new InvalidOperationException("HomeCamp has unsaved edits; save or discard manually."); EditorSceneManager.CloseScene(scene, true); return "Closed HomeCamp review scene."; } } #endif