Files

947 lines
59 KiB
C#

#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;
/// <summary>Authors HomeCamp through Pipeline; never constructs the scene at runtime.</summary>
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<Renderer>(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<Transform>(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<Material>(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<Receipt>(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<Texture2D>(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.";
}
/// <summary>Imports the v2 B/C props whose five-step receipts are ready; returns the imported ids.</summary>
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<string>(); var skipped = new List<string>();
foreach (var part in Props)
{
var path = Path.Combine(Repo, "Tools/output/homecamp_studio", part, "production.json");
var receipt = File.Exists(path) ? JsonUtility.FromJson<Receipt>(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);
}
/// <summary>Reimports cottage when its five-step receipt is ready (v2 awning side house).</summary>
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<Receipt>(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<Texture2D>(dir + "/" + part + "_base.png"));
mat.SetTexture("_MixTex", AssetDatabase.LoadAssetAtPath<Texture2D>(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<GameObject>(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<Renderer>()) r.sharedMaterial = mat;
if (part == "ground")
foreach (var f in go.GetComponentsInChildren<MeshFilter>())
{ var c = f.gameObject.AddComponent<MeshCollider>(); 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); }
}
/// <summary>Lit foliage cards, tiled XTerrain ground and post Volume; does not reimport Studio models.</summary>
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.";
}
/// <summary>Matte candy response: lower metallic/smoothness, slight albedo lift for readable diffuse.</summary>
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<Material>(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<LightingSettings>(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<Texture2D>(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<Texture2D>(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<Mesh>(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<MeshFilter>().sharedMesh = mesh;
var renderer = go.AddComponent<MeshRenderer>(); renderer.sharedMaterial = mat;
renderer.shadowCastingMode = ShadowCastingMode.Off; renderer.receiveShadows = true;
go.AddComponent<MeshCollider>().sharedMesh = mesh;
PrefabUtility.SaveAsPrefabAsset(go, Root + "/GroundSurface.prefab");
}
finally { EditorSceneManager.ClosePreviewScene(scene); }
}
static VolumeProfile BuildVolumeProfile()
{
var path = Root + "/HomeCampVolume.asset";
var profile = AssetDatabase.LoadAssetAtPath<VolumeProfile>(path);
if (!profile) { profile = ScriptableObject.CreateInstance<VolumeProfile>(); AssetDatabase.CreateAsset(profile, path); }
foreach (var old in profile.components.ToArray()) { profile.components.Remove(old); Object.DestroyImmediate(old, true); }
T Add<T>() where T : VolumeComponent
{
var c = profile.Add<T>(true); c.name = typeof(T).Name;
c.hideFlags = HideFlags.HideInInspector | HideFlags.HideInHierarchy;
AssetDatabase.AddObjectToAsset(c, profile); return c;
}
Add<Tonemapping>().mode.value = TonemappingMode.Neutral;
var color = Add<ColorAdjustments>();
// 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<WhiteBalance>(); white.temperature.value = 6; white.tint.value = 0;
var bloom = Add<Bloom>(); bloom.threshold.value = 1.15f; bloom.intensity.value = .12f; bloom.scatter.value = .55f;
var vignette = Add<Vignette>(); 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<GameObject>(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<MeshCollider>().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<Collider>());
var texture = AssetDatabase.LoadAssetAtPath<Texture2D>(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>(); renderer.sharedMaterial = AssetDatabase.LoadAssetAtPath<Material>(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<Camera>();
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>(); 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;
}
/// <summary>
/// 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.
/// </summary>
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>(); 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);
}
/// <summary>Grid of probes over the plaza so houses on LightProbes get baked sky fill, not black tetrahedra.</summary>
static void PlaceLightProbes(Transform parent)
{
var go = new GameObject("HomeCampLightProbes");
go.transform.SetParent(parent, false);
var group = go.AddComponent<LightProbeGroup>();
var positions = new System.Collections.Generic.List<Vector3>();
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<Collider>());
var renderer = go.GetComponent<MeshRenderer>();
renderer.sharedMaterial = AssetDatabase.LoadAssetAtPath<Material>(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<GameObject>(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<string>();
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<GameObject>("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<GameObject>(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<CapsuleCollider>();
if (col == null)
{
col = petGo.AddComponent<CapsuleCollider>();
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<GameObject>(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<GameObject>(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<GameObject>(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>();
volume.transform.SetParent(root.transform, false);
volume.isGlobal = true; volume.priority = 10;
volume.sharedProfile = AssetDatabase.LoadAssetAtPath<VolumeProfile>(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<LightingSettings>(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<Renderer>(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;
}
}
/// <summary>Bakes HomeCamp.unity alone, renders review captures, then restores the previously open scenes.</summary>
public static async System.Threading.Tasks.Task<string> 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<Camera>();
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<MeshRenderer>();
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<Collider>());
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<Renderer>().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<GameObject>(PartPath(p))));
static string CaptureSet(IEnumerable<string> 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<GameObject>(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<string>();
foreach (var part in Parts)
{
var p = AssetDatabase.LoadAssetAtPath<GameObject>(PartPath(part));
if (!p) throw new InvalidOperationException("Missing component " + part);
var triangles = p.GetComponentsInChildren<MeshFilter>().Sum(f => (int)f.sharedMesh.GetIndexCount(0) / 3);
var receipt = JsonUtility.FromJson<Receipt>(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<Renderer>())
if (!r.sharedMaterial || r.sharedMaterial.shader.name != "Test/HomeCampXPbr") throw new InvalidOperationException("XPbr " + part);
report.Add(part + "=" + triangles);
}
var root = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
if (!root || !File.Exists(ScenePath)) throw new InvalidOperationException("Missing assembled scene/prefab");
if (root.GetComponentsInChildren<MeshCollider>().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<MeshFilter>().Sum(f => (int)f.sharedMesh.GetIndexCount(0)/3);
}
finally { EditorSceneManager.ClosePreviewScene(preview); }
var backdrop = root.transform.Find("DistantLakeWaterfallCastle");
if (!backdrop || backdrop.GetComponent<Collider>()) throw new InvalidOperationException("Backdrop collider");
var bg = backdrop.GetComponent<Renderer>();
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<Renderer>();
if (groundRenderer.sharedMaterial.shader.name != "XWorld/XTerrain" || !groundRenderer.receiveShadows)
throw new InvalidOperationException("Ground must be tiled XTerrain receiving shadows");
var unlitGameplay = root.GetComponentsInChildren<Renderer>(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<Volume>();
if (!volume || !volume.sharedProfile || !volume.sharedProfile.Has<Tonemapping>()) throw new InvalidOperationException("Missing post Volume");
var cards = root.GetComponentsInChildren<Renderer>(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<Camera>();
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