diff --git a/Assets/Scenes/Game.unity b/Assets/Scenes/Game.unity
index 706f90a1a..fbf25bdbf 100644
--- a/Assets/Scenes/Game.unity
+++ b/Assets/Scenes/Game.unity
@@ -40011,6 +40011,68 @@ Transform:
m_CorrespondingSourceObject: {fileID: 6078297801174817557, guid: 66d4f7c006b77084da3929add6d06f24, type: 3}
m_PrefabInstance: {fileID: 1002930408}
m_PrefabAsset: {fileID: 0}
+--- !u!1 &1007206781
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1007206783}
+ - component: {fileID: 1007206782}
+ m_Layer: 0
+ m_Name: AmbientMotion
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!114 &1007206782
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1007206781}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: c5eaa0f5b116ec34695d5e4646784462, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: ProjectM.Client::ProjectM.Client.AmbientMotionConfig
+ Enabled: 1
+ DriftEnabled: 1
+ DriftRate: 28
+ DriftSize: 0.09
+ MeadowMoteColor: {r: 0.62, g: 0.85, b: 0.6, a: 0.35}
+ AridDustColor: {r: 0.85, g: 0.68, b: 0.45, a: 0.28}
+ CavernMoteColor: {r: 0.55, g: 0.65, b: 0.95, a: 0.33}
+ BlightSporeColor: {r: 0.72, g: 0.5, b: 0.85, a: 0.33}
+ AridWind: {x: 2.2, y: -0.15, z: 0.5}
+ SwayEnabled: 1
+ SwayRadius: 30
+ SwayAmpDeg: 1.4
+ SwayHz: 0.8
+ RustleEnabled: 1
+ RustleRadius: 1.7
+ RustleAmpDeg: 7
+ RustleHz: 9
+ RustleDecay: 2.5
+--- !u!4 &1007206783
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1007206781}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1001 &1010024963
PrefabInstance:
m_ObjectHideFlags: 0
@@ -77186,6 +77248,7 @@ GameObject:
m_Component:
- component: {fileID: 2045829909}
- component: {fileID: 2045829910}
+ - component: {fileID: 2045829911}
m_Layer: 0
m_Name: CabinLight
m_TagString: Untagged
@@ -77273,6 +77336,35 @@ Light:
m_LightUnit: 1
m_LuxAtDistance: 1
m_EnableSpotReflector: 1
+--- !u!114 &2045829911
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 2045829908}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 474bcb49853aa07438625e644c072ee6, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalAdditionalLightData
+ m_UsePipelineSettings: 1
+ m_AdditionalLightsShadowResolutionTier: 2
+ m_CustomShadowLayers: 0
+ m_LightCookieSize: {x: 1, y: 1}
+ m_LightCookieOffset: {x: 0, y: 0}
+ m_SoftShadowQuality: 0
+ m_RenderingLayersMask:
+ serializedVersion: 0
+ m_Bits: 1
+ m_ShadowRenderingLayersMask:
+ serializedVersion: 0
+ m_Bits: 1
+ m_Version: 4
+ m_LightLayerMask: 1
+ m_ShadowLayerMask: 1
+ m_RenderingLayers: 1
+ m_ShadowRenderingLayers: 1
--- !u!1001 &2047680188
PrefabInstance:
m_ObjectHideFlags: 0
@@ -81293,3 +81385,4 @@ SceneRoots:
- {fileID: 1851179016}
- {fileID: 70737666}
- {fileID: 1276835916}
+ - {fileID: 1007206783}
diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientMotionConfig.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionConfig.cs
new file mode 100644
index 000000000..5073895c9
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionConfig.cs
@@ -0,0 +1,48 @@
+using UnityEngine;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Live-tunable knobs for — the Phase 1.5 "world alive" ambient layer
+ /// (per-biome drift particles + idle foliage sway + walk-through rustle). Mirrors the
+ /// / bridge idiom: a MonoBehaviour in
+ /// the gameplay scene with a static the client presentation system reads (falling
+ /// back to these same defaults when absent). Pure presentation — never touches the simulation.
+ ///
+ public sealed class AmbientMotionConfig : MonoBehaviour
+ {
+ public static AmbientMotionConfig Instance;
+
+ [Header("Master")]
+ public bool Enabled = true;
+
+ [Header("Biome drift particles (emitter follows the camera; sim space stays world)")]
+ public bool DriftEnabled = true;
+ [Min(0f)] public float DriftRate = 28f;
+ [Min(0.01f)] public float DriftSize = 0.09f;
+ public Color MeadowMoteColor = new Color(0.62f, 0.85f, 0.60f, 0.35f);
+ public Color AridDustColor = new Color(0.85f, 0.68f, 0.45f, 0.28f);
+ public Color CavernMoteColor = new Color(0.55f, 0.65f, 0.95f, 0.33f);
+ public Color BlightSporeColor = new Color(0.72f, 0.50f, 0.85f, 0.33f);
+ public Vector3 AridWind = new Vector3(2.2f, -0.15f, 0.5f);
+
+ [Header("Idle foliage sway (flora prop roots within view of the camera)")]
+ public bool SwayEnabled = true;
+ [Min(1f)] public float SwayRadius = 30f;
+ [Min(0f)] public float SwayAmpDeg = 1.4f;
+ [Min(0.05f)] public float SwayHz = 0.8f;
+
+ [Header("Walk-through rustle (local player brushing flora)")]
+ public bool RustleEnabled = true;
+ [Min(0.2f)] public float RustleRadius = 1.7f;
+ [Min(0f)] public float RustleAmpDeg = 7f;
+ [Min(0.5f)] public float RustleHz = 9f;
+ [Min(0.2f)] public float RustleDecay = 2.5f;
+
+ void OnEnable() { Instance = this; }
+ void OnDisable() { if (Instance == this) Instance = null; }
+
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
+ static void ResetStatics() { Instance = null; }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientMotionConfig.cs.meta b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionConfig.cs.meta
new file mode 100644
index 000000000..39f99d28c
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionConfig.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c5eaa0f5b116ec34695d5e4646784462
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs
new file mode 100644
index 000000000..d727d47ef
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs
@@ -0,0 +1,254 @@
+using System.Collections.Generic;
+using ProjectM.Simulation;
+using Unity.Entities;
+using Unity.NetCode;
+using Unity.Transforms;
+using UnityEngine;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Client-only AMBIENT MOTION layer (Phase 1.5 "world alive" — the anti-static beat, zero netcode):
+ /// - per-biome DRIFT particles (meadow motes / arid wind-blown dust / cavern motes / blight spores) from
+ /// one world-space emitter that follows the camera; palette keys on camera-X region + the replicated
+ /// per-room biome, mirroring 's switch
+ /// - idle SWAY on the cosmetic flora prop roots near the camera (Game.unity classic layer; probed
+ /// 2026-07-09: 212 flora roots, none static-batched, LOD children follow the root)
+ /// - walk-through RUSTLE: flora brushed by the LOCAL player (the GhostOwnerIsLocal idiom) gets an
+ /// energy pulse that shakes and decays
+ /// Observe-only in ; wall-clock time is fine
+ /// (presentation). Rotations write around a cached base so the layer is lossless when toggled off.
+ /// Knobs live in (defaults when absent).
+ ///
+ [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
+ [UpdateInGroup(typeof(PresentationSystemGroup))]
+ public partial class AmbientMotionSystem : SystemBase
+ {
+ static readonly System.Text.RegularExpressions.Regex FloraName = new System.Text.RegularExpressions.Regex(
+ "bush|grass|flower|fern|groundcover|ground_cover|wildflower|clover|reed|succulent|cactus",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase);
+
+ struct Flora
+ {
+ public Transform T;
+ public Vector3 Pos;
+ public Quaternion BaseRot;
+ public float Phase;
+ public float RustleEnergy;
+ }
+
+ readonly List _flora = new List(256);
+ bool _floraCached;
+ string _sceneKey;
+ Camera _cam;
+ GameObject _root;
+ ParticleSystem _drift;
+ int _biomeKey = -1;
+
+ protected override void OnDestroy()
+ {
+ RestoreFlora();
+ if (_root != null) Object.Destroy(_root);
+ }
+
+ protected override void OnUpdate()
+ {
+ var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
+ if (scene.name != "Game") { RestoreFlora(); _floraCached = false; return; }
+ if (_cam == null) _cam = Camera.main;
+ if (_cam == null) return;
+
+ var cfg = AmbientMotionConfig.Instance;
+ bool enabled = cfg == null || cfg.Enabled;
+ if (!enabled)
+ {
+ RestoreFlora();
+ if (_drift != null && _drift.isPlaying) _drift.Stop();
+ return;
+ }
+
+ if (!_floraCached || _sceneKey != scene.path) CacheFlora(scene);
+
+ UpdateDrift(cfg);
+ UpdateSwayAndRustle(cfg);
+ }
+
+ // ---------- flora cache ----------
+
+ void CacheFlora(UnityEngine.SceneManagement.Scene scene)
+ {
+ RestoreFlora();
+ _flora.Clear();
+ foreach (var root in scene.GetRootGameObjects())
+ {
+ if (root.name != "BaseBiome" && !root.name.StartsWith("ExpeditionBiome")) continue;
+ foreach (var t in root.GetComponentsInChildren(false))
+ {
+ if (!FloraName.IsMatch(t.gameObject.name)) continue;
+ if (t.gameObject.name.Contains("LOD")) continue; // prop roots only; LOD children follow
+ if (t.GetComponent() == null && t.GetComponent() == null) continue;
+ var p = t.position;
+ _flora.Add(new Flora
+ {
+ T = t,
+ Pos = p,
+ BaseRot = t.localRotation,
+ Phase = (p.x * 12.9898f + p.z * 78.233f) % 6.2831f,
+ RustleEnergy = 0f,
+ });
+ }
+ }
+ _floraCached = true;
+ _sceneKey = scene.path;
+ }
+
+ void RestoreFlora()
+ {
+ for (int i = 0; i < _flora.Count; i++)
+ if (_flora[i].T != null) _flora[i].T.localRotation = _flora[i].BaseRot;
+ }
+
+ // ---------- biome drift particles ----------
+
+ void UpdateDrift(AmbientMotionConfig cfg)
+ {
+ bool driftOn = cfg == null || cfg.DriftEnabled;
+ if (!driftOn)
+ {
+ if (_drift != null && _drift.isPlaying) _drift.Stop();
+ return;
+ }
+ if (_root == null)
+ {
+ _root = new GameObject("~AmbientMotion");
+ Object.DontDestroyOnLoad(_root);
+ }
+ if (_drift == null)
+ {
+ var mat = FeedbackFx.MakeParticleMaterial("AmbientDrift");
+ _drift = FeedbackFx.MakeBurst(_root.transform, "Drift", mat, Color.white,
+ 0.09f, 0.15f, 9f, 600, gravity: 0f, shapeRadius: 1f, sizeTail: 0.9f);
+ var main = _drift.main;
+ main.simulationSpace = ParticleSystemSimulationSpace.World;
+ main.startLifetime = 9f;
+ var shape = _drift.shape;
+ shape.enabled = true;
+ shape.shapeType = ParticleSystemShapeType.Box;
+ shape.scale = new Vector3(36f, 7f, 24f);
+ var noise = _drift.noise;
+ noise.enabled = true;
+ noise.strength = 0.45f;
+ noise.frequency = 0.25f;
+ var emission = _drift.emission;
+ emission.enabled = true;
+ }
+
+ // palette key: base hub vs the replicated per-room biome (WorldAtmosphereSystem's switch shape)
+ int key = 0; // 0 = meadow/base
+ if (_cam.transform.position.x > 500f)
+ {
+ key = 1; // arid default
+ if (SystemAPI.TryGetSingleton(out var ri) && ri.Lifecycle != RunLifecycle.Staging)
+ {
+ switch (ri.CurrentBiome)
+ {
+ case RoomBiomeId.Meadow: key = 0; break;
+ case RoomBiomeId.Cavern: key = 2; break;
+ case RoomBiomeId.Blight: key = 3; break;
+ }
+ }
+ }
+ if (key != _biomeKey)
+ {
+ _biomeKey = key;
+ var main = _drift.main;
+ main.startSize = cfg != null ? cfg.DriftSize : 0.09f;
+ Color c;
+ Vector3 wind;
+ switch (key)
+ {
+ case 1: c = cfg != null ? cfg.AridDustColor : new Color(0.85f, 0.68f, 0.45f, 0.28f); wind = cfg != null ? cfg.AridWind : new Vector3(2.2f, -0.15f, 0.5f); break;
+ case 2: c = cfg != null ? cfg.CavernMoteColor : new Color(0.55f, 0.65f, 0.95f, 0.33f); wind = new Vector3(0f, 0.18f, 0f); break;
+ case 3: c = cfg != null ? cfg.BlightSporeColor : new Color(0.72f, 0.50f, 0.85f, 0.33f); wind = new Vector3(0f, 0.28f, 0f); break;
+ default: c = cfg != null ? cfg.MeadowMoteColor : new Color(0.62f, 0.85f, 0.60f, 0.35f); wind = new Vector3(0f, 0.22f, 0f); break;
+ }
+ main.startColor = c;
+ var vel = _drift.velocityOverLifetime;
+ vel.enabled = true;
+ vel.space = ParticleSystemSimulationSpace.World;
+ vel.x = wind.x; vel.y = wind.y; vel.z = wind.z;
+ var emission = _drift.emission;
+ emission.rateOverTime = cfg != null ? cfg.DriftRate : 28f;
+ }
+ if (!_drift.isPlaying) _drift.Play();
+ // follow the camera's ground focus so the volume always blankets the view
+ var camPos = _cam.transform.position;
+ _drift.transform.position = new Vector3(camPos.x, 3.5f, camPos.z);
+ }
+
+ // ---------- sway + rustle ----------
+
+ void UpdateSwayAndRustle(AmbientMotionConfig cfg)
+ {
+ bool swayOn = cfg == null || cfg.SwayEnabled;
+ bool rustleOn = cfg == null || cfg.RustleEnabled;
+ if (!swayOn && !rustleOn) { RestoreFlora(); return; }
+
+ float swayRadius = cfg != null ? cfg.SwayRadius : 30f;
+ float swayAmp = cfg != null ? cfg.SwayAmpDeg : 1.4f;
+ float swayHz = cfg != null ? cfg.SwayHz : 0.8f;
+ float rustleRadius = cfg != null ? cfg.RustleRadius : 1.7f;
+ float rustleAmp = cfg != null ? cfg.RustleAmpDeg : 7f;
+ float rustleHz = cfg != null ? cfg.RustleHz : 9f;
+ float rustleDecay = cfg != null ? cfg.RustleDecay : 2.5f;
+
+ // local player position (rustle source); missing (menu/no ghost yet) -> far away
+ Vector3 playerPos = new Vector3(1e9f, 0f, 1e9f);
+ foreach (var lt in SystemAPI.Query>().WithAll())
+ playerPos = new Vector3(lt.ValueRO.Position.x, 0f, lt.ValueRO.Position.z);
+
+ float t = UnityEngine.Time.time;
+ float dt = UnityEngine.Time.deltaTime;
+ var camPos = _cam.transform.position;
+ float swayR2 = swayRadius * swayRadius;
+ float rustleR2 = rustleRadius * rustleRadius;
+
+ for (int i = 0; i < _flora.Count; i++)
+ {
+ var f = _flora[i];
+ if (f.T == null) continue;
+
+ float dxc = f.Pos.x - camPos.x, dzc = f.Pos.z - camPos.z;
+ bool nearCam = dxc * dxc + dzc * dzc < swayR2;
+
+ if (rustleOn)
+ {
+ float dxp = f.Pos.x - playerPos.x, dzp = f.Pos.z - playerPos.z;
+ if (dxp * dxp + dzp * dzp < rustleR2) f.RustleEnergy = 1f;
+ else if (f.RustleEnergy > 0f) f.RustleEnergy = Mathf.Max(0f, f.RustleEnergy - rustleDecay * dt);
+ }
+
+ if (!nearCam && f.RustleEnergy <= 0f)
+ {
+ _flora[i] = f;
+ continue;
+ }
+
+ float ax = 0f, az = 0f;
+ if (swayOn && nearCam)
+ {
+ ax += swayAmp * Mathf.Sin(t * swayHz * 6.2831f + f.Phase);
+ az += swayAmp * 0.8f * Mathf.Sin(t * swayHz * 5.5f + f.Phase * 1.7f);
+ }
+ if (f.RustleEnergy > 0f)
+ {
+ float e = f.RustleEnergy;
+ ax += rustleAmp * e * Mathf.Sin(t * rustleHz * 6.2831f + f.Phase);
+ az += rustleAmp * 0.7f * e * Mathf.Sin(t * rustleHz * 5.1f + f.Phase * 2.3f);
+ }
+ f.T.localRotation = f.BaseRot * Quaternion.Euler(ax, 0f, az);
+ _flora[i] = f;
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs.meta
new file mode 100644
index 000000000..1009a0bc2
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a4a8e8f6d3c4d95428cf227b39f5fc30
\ No newline at end of file
diff --git a/Assets/_Project/Shaders/PixelOutline.mat b/Assets/_Project/Shaders/PixelOutline.mat
index ce0565c9f..8e4427809 100644
--- a/Assets/_Project/Shaders/PixelOutline.mat
+++ b/Assets/_Project/Shaders/PixelOutline.mat
@@ -28,16 +28,16 @@ Material:
- _Brightness: 1.1
- _ColorLevels: 12
- _Contrast: 1.05
- - _DepthEdgesEnabled: 0
+ - _DepthEdgesEnabled: 1
- _DepthThreshold: 0.08
- _EdgeStrength: 1
- - _MasterEnabled: 1
- - _NormalEdgesEnabled: 0
+ - _MasterEnabled: 0
+ - _NormalEdgesEnabled: 1
- _NormalThreshold: 1
- _OutlineThickness: 1
- - _PixelHeight: 200
- - _PixelateEnabled: 0
- - _PosterizeEnabled: 0
+ - _PixelHeight: 399.82455
+ - _PixelateEnabled: 1
+ - _PosterizeEnabled: 1
- _Saturation: 1.35
m_Colors:
- _OutlineColorInner: {r: 0, g: 0, b: 0, a: 1}