885 lines
55 KiB
C#
885 lines
55 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>();
|
|
// Noon frame + another ~20% brightness (EV += log2(1.2)); mild contrast keeps shade soft-PBR.
|
|
color.postExposure.value = .74f; 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;
|
|
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: high elevation, +30% intensity vs prior look-dev; shade deepened in bake/shaders.
|
|
light.color = new Color(1, .98f, .94f); light.intensity = 2.15f; light.shadows = LightShadows.Soft;
|
|
light.lightmapBakeType = LightmapBakeType.Baked; light.shadowAngle = 4;
|
|
light.shadowStrength = .94f; 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;
|
|
var halfHeight = distance * Mathf.Tan(camera.fieldOfView * Mathf.Deg2Rad * .5f);
|
|
var width = halfHeight * 2 * 1080f / 1920;
|
|
var height = width / 1.5f;
|
|
go.transform.position = camera.transform.position + camera.transform.forward * distance + camera.transform.up * (halfHeight - height * .5f);
|
|
go.transform.rotation = camera.transform.rotation;
|
|
go.transform.localScale = new Vector3(width * 1.04f, height * 1.04f, 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);
|
|
// 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
|