AR Sandbox Projection · 工程复盘(独立开发)

系统采用 Unity(C#)与 Orbbec/Astra 深度相机,包含深度→网格、标定/投影、ROI 联动、Shader 参数中台、生态分布与交互、稳定性工程化等完整链路。
开发需求文档与思维导图:Sandbox: https://www.mubu.com/doc/EP5Q5NheCX
产品相关展示:https://github.com/xiaoyunhai0/ARSandBox

0) 运行环境

  • 引擎:Unity(建议 2021+,内置或 URP 均可)
  • 硬件:Orbbec/Astra(深度 + 彩色)
  • UI/依赖:UGUI + TextMeshPro;参数持久化使用 PlayerPrefs
  • 部署:Windows x64,双显示器(主屏控制 + 副屏投影)

1) 项目目标与成果

  • 将 Astra 深度数据实时重建为 Unity 网格(Mesh),实现物理等比投影对齐
  • 通过 ROI 双窗口联动(Mesh/Color)完成区域标定与像素级裁剪
  • 构建 Shader 参数中台,统一水陆阈值、等高线、主题/天气;生态/显示共享同一“世界解释”。
  • 提供 副屏投影模板(正交相机 + 动态 RenderTexture 自适应)与一站式标定接口
  • 叠加 轻量生态系统(鱼/鸟/走兽/鲨/宝箱 + 捕鱼模式),表现可控、耦合低。
  • 关键参数全链路持久化,按 SOP 快速布展与复现。

2) 系统结构

1
2
3
4
5
6
7
8
9
Astra 深度/彩色帧
└─ AstraMeshController(深度→网格;等比/拉平/矫正/落盘)
├─ ProjectionCalibrationController(Mesh/投影相机 一站式标定)
├─ ROIManager + ROICornerDragger(ROI 双窗口角点联动)
├─ AstraDisplayUI(彩色裁剪、滤波切换、UI 联动)
├─ DynamicRT(RawImage ↔ RenderTexture 自适应)
├─ MaterialParamManager + SandboxSettingsUI(Shader 参数中台)
├─ WeatherManager(主题/天气与光照/雾效联动)
└─ ModelSpawnerManager + Fish/Bird/Beast/Shark/Treasure/Fishing(生态)

3) 目录(核心脚本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
AstraMeshController.cs
ProjectionCalibrationController.cs
DynamicRT.cs
ROIManager.cs
ROICornerDragger.cs
AstraDisplayUI.cs
MaterialParamManager.cs
SandboxSettingsUI.cs
WeatherManager.cs
ModelSpawnerManager.cs
FishController.cs
BirdController.cs
BeastController.cs
SharkSpawner.cs
SharkController.cs
SharkSettingsUI.cs
TreasureChestController.cs
FishingManager.cs

4) 关键模块与源码

4.1 深度→网格与物理对齐(AstraMeshController.cs

职责:深度帧→三角网/UV/顶点;1:1 等比;围栏拉平;全局偏移/旋转/基准抬升;参数持久化。

要点xyScale/zScale + useRealWorldSize/realWorldSize;边界采样 + 平面拟合;PlayerPrefs 落盘。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#region Mesh Params Save/Load(网格参数落盘/恢复)
public void SaveMeshParamsToPrefs()
{
PlayerPrefs.SetFloat("Mesh_HorizontalOffset", positionOffset.x); // XZ 偏移-X
PlayerPrefs.SetFloat("Mesh_VerticalOffset", positionOffset.z); // XZ 偏移-Z
PlayerPrefs.SetFloat("Mesh_BaseOffset", baseOffset); // 全局抬升-Y
PlayerPrefs.SetFloat("Mesh_RotationY", rotationOffset.y); // 绕 Y 微调
PlayerPrefs.Save();
}
public void LoadMeshParamsFromPrefs()
{
positionOffset.x = PlayerPrefs.GetFloat("Mesh_HorizontalOffset", 0f);
positionOffset.z = PlayerPrefs.GetFloat("Mesh_VerticalOffset", 0f);
baseOffset = PlayerPrefs.GetFloat("Mesh_BaseOffset", 0f);
rotationOffset.y = PlayerPrefs.GetFloat("Mesh_RotationY", 0f);
}
#endregion

#region Fence Level(围栏拉平:边界采样 + 平面拟合)
[ContextMenu("Fence Level Now")]
public void FenceLevelCorrectionRequest()
{
fenceLevelCorrectionRequested = false;
UpdateMeshVertices(); // 内部触发 FenceLevelCorrect(originalVertices)
}
private void FenceLevelCorrect(Vector3[] verts)
{
var borderPoints = new List<Vector3>(); // 1) 采集四周边界点(剔除异常值)
// ... 收集边界点到 borderPoints
Plane plane = FitPlaneToPoints(borderPoints); // 2) 最小二乘拟合平面
Quaternion deltaRot = Quaternion.FromToRotation(transform.up, plane.normal); // 3) 旋转矫正
transform.rotation = deltaRot * transform.rotation;
transform.position += new Vector3(0f, baseOffset, 0f); // 4) 基准抬升
}
#endregion

#region Public Access(对外接口)
public Vector3[] GetMeshVertices() => originalVertices;
public void SetRotationOffsetY(float ry) => rotationOffset.y = ry;
public float GetRotationOffsetY() => rotationOffset.y;
#endregion

4.2 投影与多显示模板(ProjectionCalibrationController.csDynamicRT.cs

职责:集中暴露 Mesh/相机几何参数;正交相机副屏输出;RawImage→RT 自适应,仅在尺寸变化时重建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ProjectionCalibrationController.cs
#region One-Stop Calibration(标定入口)
public void SetMeshScaleX(float v) => meshCtrl.realWorldSize.x = v; // 物理宽
public void SetMeshScaleZ(float v) => meshCtrl.realWorldSize.y = v; // 物理深
public void SetMeshBaseOffset(float v) => meshCtrl.SetBaseOffset(v); // 全局抬升
public void SetMeshRotY(float v) => meshCtrl.SetRotationOffsetY(v); // Y 旋

public void SetCameraX(float v){ var p=projectorCamera.transform.position; p.x=v; projectorCamera.transform.position=p; }
public void SetCameraY(float v){ var p=projectorCamera.transform.position; p.y=v; projectorCamera.transform.position=p; }
public void SetCameraZ(float v){ var p=projectorCamera.transform.position; p.z=v; projectorCamera.transform.position=p; }
public void SetCameraOrthoSize(float v) => projectorCamera.orthographicSize = v;

public void MeshFenceLevel() => meshCtrl.FenceLevelCorrectionRequest(); // 一键拉平
#endregion
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// DynamicRT.cs
#region DynamicRT(按需重建 RT 并同步相机宽高比)
void Update() => UpdateRenderTexture();
void UpdateRenderTexture()
{
var rect = rawImage.rectTransform.rect;
int w = Mathf.Max(1, Mathf.RoundToInt(rect.width));
int h = Mathf.Max(1, Mathf.RoundToInt(rect.height));
if (rt == null || rt.width != w || rt.height != h)
{
if (rt != null) rt.Release();
rt = new RenderTexture(w, h, 16);
renderCam.targetTexture = rt;
rawImage.texture = rt;
renderCam.aspect = (float)w / h; // 防止拉伸
}
}
#endregion

4.3 ROI 双窗口联动与彩色裁剪(ROIManager.csROICornerDragger.csAstraDisplayUI.cs

职责:Mesh/Color 两窗口角点拖拽双向同步;彩色帧像素级裁剪;角点/ROI 落盘恢复;局部相机随 ROI 自动对齐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ROIManager.cs
#region ROI Save/Load(角点落盘与延迟恢复)
public void SaveROIToPrefs()
{
for (int i = 0; i < 4; i++)
{
var pos = roiCornersMesh[i].anchoredPosition;
PlayerPrefs.SetFloat($"ROI_Corner_{i}_X", pos.x);
PlayerPrefs.SetFloat($"ROI_Corner_{i}_Y", pos.y);
}
PlayerPrefs.Save();
}
IEnumerator DelayLoadROI()
{
bool wasActive = calibrationPanel.activeSelf;
calibrationPanel.SetActive(true); // 强制激活,确保 Canvas 完成布局
yield return null; // 等一帧,等待布局稳定
LoadROIFromPrefs();
UpdateAll(); // 同步 ROI、相机与裁剪
calibrationPanel.SetActive(wasActive);
}
#endregion
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ROICornerDragger.cs
#region Corner Drag(角点拖拽与双向同步)
public void OnBeginDrag(PointerEventData e)
{
RectTransformUtility.ScreenPointToLocalPointInRectangle(
parentRawImageRect, e.position, e.pressEventCamera, out startLocal);
startOffset = (Vector2)transform.localPosition - startLocal;
}
public void OnDrag(PointerEventData e)
{
RectTransformUtility.ScreenPointToLocalPointInRectangle(
parentRawImageRect, e.position, e.pressEventCamera, out Vector2 local);
Vector2 newPos = local + startOffset;
var r = parentRawImageRect.rect;
newPos.x = Mathf.Clamp(newPos.x, r.xMin, r.xMax);
newPos.y = Mathf.Clamp(newPos.y, r.yMin, r.yMax);
((RectTransform)transform).anchoredPosition = newPos;
roiManager.OnCornerMoved(cornerIndex, belongsToMeshWindow: true); // 双向同步
}
#endregion
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// AstraDisplayUI.cs
#region ROI Crop(像素级裁剪输出)
private Texture2D _roiTex;
void CropAndShowROI(Texture2D src, RectInt roi)
{
roi.x = Mathf.Clamp(roi.x, 0, Mathf.Max(0, src.width - 1));
roi.y = Mathf.Clamp(roi.y, 0, Mathf.Max(0, src.height - 1));
roi.width = Mathf.Clamp(roi.width, 1, src.width - roi.x);
roi.height = Mathf.Clamp(roi.height, 1, src.height - roi.y);
var pixels = src.GetPixels(roi.x, roi.y, roi.width, roi.height);
if (_roiTex == null || _roiTex.width != roi.width || _roiTex.height != roi.height)
_roiTex = new Texture2D(roi.width, roi.height, TextureFormat.RGBA32, false);
_roiTex.SetPixels(pixels);
_roiTex.Apply(false, false);
roiPreviewRawImage.texture = _roiTex;
}
#endregion

#region UI Shortcut(滤波 σ 快切)
void Update()
{
if (Input.GetKeyDown(KeyCode.Alpha5))
ToggleGaussianSigma();
}
#endregion

4.4 Shader 参数中台与分层阈值(MaterialParamManager.csSandboxSettingsUI.cs

职责:统一封装地形/火山/雪/等高线;语义化接口;UI 双向绑定与边界有序约束;阈值驱动生态与显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// MaterialParamManager.cs
#region Terrain Params(地形分层/等高线)
public void SetTerrainBorder0(float v) => SetFloat("terrain", "_Border0", v);
public void SetTerrainBorder1(float v) => SetFloat("terrain", "_Border1", v);
public void SetTerrainBorder2(float v) => SetFloat("terrain", "_Border2", v);
public void SetTerrainBlendRange(float v) => SetFloat("terrain", "_BlendRange", v);
public void SetContourSpacing(float v) => SetFloat("terrain", "_ContourSpacing", v);
public void SetContourSmoothness(float v)=> SetFloat("terrain", "_ContourSmoothness", v);
public void SetContourColor(Color c) => SetColor("terrain", "_ContourColor", c);

private readonly Dictionary<string, Material> matDict = new();
private void SetFloat(string name, string prop, float value)
{
if (matDict.TryGetValue(name, out var m) && m.HasProperty(prop))
m.SetFloat(prop, value);
}
private void SetColor(string name, string prop, Color value)
{
if (matDict.TryGetValue(name, out var m) && m.HasProperty(prop))
m.SetColor(prop, value);
}
#endregion
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// SandboxSettingsUI.cs
#region UI Binding(滑条/输入框双向绑定 + 边界有序约束)
void InitSliderAndInput(Slider s, TMP_InputField i, float min, float max, float def, Action<float> onChanged)
{
s.minValue = min; s.maxValue = max; s.SetValueWithoutNotify(def);
i.text = def.ToString("F2");
s.onValueChanged.AddListener(v => { i.SetTextWithoutNotify(v.ToString("F2")); onChanged(v); Save(); });
i.onEndEdit.AddListener(t => {
if (float.TryParse(t, out var v)) { v = Mathf.Clamp(v, min, max); s.SetValueWithoutNotify(v); onChanged(v); Save(); }
});
}
void EnforceBordersOrder() // Border0 < Border1 < Border2
{
float b0 = GetSlider(terrainBorder0Slider);
float b1 = Mathf.Max(b0 + 0.01f, GetSlider(terrainBorder1Slider));
float b2 = Mathf.Max(b1 + 0.01f, GetSlider(terrainBorder2Slider));
terrainBorder1Slider.SetValueWithoutNotify(b1);
terrainBorder2Slider.SetValueWithoutNotify(b2);
}
#endregion

4.5 生态分布与行为(ModelSpawnerManager.csSharkSpawner.csSharkController.cs 等)

职责:按阈值与网格生成 water/ground/fly 点集;行为巡航带最小位移/最大距离越界预测;UI/快捷键批量控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ModelSpawnerManager.cs
#region Points Generation(水/地/空点集)
public void RefreshAllModelPoints()
{
if (!IsMeshReady()) return;
var mat = matMgr.terrainMaterial;
float b0 = mat.GetFloat("_Border0");
float b2 = mat.GetFloat("_Border2");
float blend = mat.GetFloat("_BlendRange");
float waterThreshold = b2 - blend;
var verts = meshCtrl.GetMeshVertices();
waterPoints = verts.Where(v => v.y >= waterThreshold).ToList();
groundPoints = verts.Where(v => v.y > b0 && v.y < b2).ToList();
flyPoints = verts.ToList();
}
#endregion
1
2
3
4
5
6
7
8
9
10
11
// SharkSpawner.cs
#region Shark Sync(下发水面高度与点集)
void Update()
{
float b2 = matParamMgr.terrainMaterial.GetFloat("_Border2");
float blend = matParamMgr.terrainMaterial.GetFloat("_BlendRange");
SharkController.GlobalWaterBorder = b2 - blend;
foreach (var s in sharks)
s.GetComponent<SharkController>().waterPoints = waterPoints;
}
#endregion
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// SharkController.cs
#region Shark Target Pick(目标选择:去抖/限距/越界预测)
void PickNewTarget(bool isFirst = false)
{
if (waterPoints == null || waterPoints.Count == 0)
{ targetPos = transform.localPosition; return; }
Vector3 cur = transform.localPosition;
float maxSqr = maxTargetDistance * maxTargetDistance;
float minSqr = minMoveDist * minMoveDist;
var cand = new List<Vector3>();
foreach (var wp in waterPoints)
{
float d2 = (new Vector3(wp.x, GlobalWaterBorder, wp.z) - cur).sqrMagnitude;
if (d2 > minSqr && d2 < maxSqr) cand.Add(wp);
}
var pick = cand.Count > 0 ? cand[Random.Range(0, cand.Count)] : waterPoints[Random.Range(0, waterPoints.Count)];
targetPos = new Vector3(pick.x, GlobalWaterBorder, pick.z);
}
#endregion
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// TreasureChestController.cs
#region Chest Appear/Hide(阈值触发显隐)
public void ShowChest()
{
if (isAppeared) return;
isAppeared = true;
var pos = transform.position;
pos.y = chestShowYOffset; // 提升到可见高度
transform.position = pos;
SetChildrenActive(true);
if (chestAnim) chestAnim.SetTrigger("Appear");
Invoke(nameof(HideChest), 5f); // 自动隐藏
}
#endregion

4.6 主题/天气与地图切换(WeatherManager.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#region Weather/Map(天气/地图)
public void DoClear() => SetWeather(WeatherType.Clear);
public void DoRain() => SetWeather(WeatherType.Rain);
public void DoSnow() => SetWeather(WeatherType.Snow);
public void ShowMap0() => SwitchMap(0);
public void ShowMap1() => SwitchMap(1);

public void SetWeather(WeatherType t)
{
// 关闭上一个天气 → 启用当前(粒子/叠加材质)
// 调整主光强与雾效,保证视觉一致
// ...
}
#endregion

5) 标定与演示 SOP

  1. 分层优先:在设置面板调 _Border0/_Border1/_Border2/_BlendRange,先稳定“水面阈值/地表层级”。
  2. ROI 校准:Mesh/Color 两窗口四角拖拽(双向同步),自动落盘;彩色帧按 ROI 像素级裁剪
  3. 姿态/尺寸:用一站式接口调整 Mesh 偏移/旋转/等比尺寸与投影相机 OrthoSize;围栏歪斜用“一键拉平”。
  4. 主题/天气:切换 Clear/Rain/Snow/Volcano 与 Map0/Map1(光照/雾效联动)。
  5. 生态/互动F1/F2/F3 刷鱼/鸟/走兽,F5 刷新全部,F6 清空;F8 捕鱼模式;F7 调试显宝箱。
  6. 持久化:Mesh/ROI/Shader/UI 参数统一 PlayerPrefs 落盘,二次启动即用。

6) 快捷键速查

功能 快捷键
滤波 σ 快切 5
鱼 / 鸟 / 走兽 刷新 F1 / F2 / F3
全部刷新 / 清空 F5 / F6
鲨鱼 刷新 / 清空 3 / 4
捕鱼模式 F8
天气:雨 / 雪 / 晴 / 火山 R / S / C / V
地图切换 1 / 2
宝箱显现(调试) F7

7) 稳定性与工程化

  • 按需分配:RT/纹理尺寸变更才重建;实例池按目标数增删,减少 Instantiate/Destroy
  • 越界与抖动:最小位移/最大距离 + 下一帧越界预测,不合法即重选。
  • UI 约束SetValueWithoutNotify 防事件回环;EnforceBordersOrder 保证边界有序。
  • 日志可观测:聚焦刷新/清空/强制动作;发布构建统一降级日志级别。
  • 异常防护:空引用/尺寸越界/ROI 出界均有 Clamp 与判空,避免运行期崩溃。

8) 独立负责与落地产出(Solo Ownership)

  • 深度→网格:实时重建、等比标定、围栏拉平、全局矫正、参数持久化。
  • 投影/多显示:正交相机模板、动态 RT 自适应、一站式标定接口。
  • ROI 系统:双窗口角点联动、像素级裁剪、延迟恢复与落盘。
  • Shader 中台:分层阈值与等高线/火山/雪等主题的语义化接口。
  • 生态/互动:阈值驱动的水/地/空区域分布与行为(鱼/鸟/走兽/鲨/宝箱/捕鱼)。
  • SOP 与文档:现场标定流程、快捷键体系、排障要点。

9) 常见问题(快速排查)

  • ROI 初次进入错位DelayLoadROI() 先激活校准面板并等一帧再恢复角点;必要时调用 Canvas.ForceUpdateCanvases()
  • 投影画面拉伸/糊:确认 DynamicRT 正在按 RawImage 像素尺寸重建 RT,并同步 renderCam.aspect
  • 分层边界“打架”:UI 以 EnforceBordersOrder() 保证 Border0 < Border1 < Border2,回写材质前 Clamp。
  • 鱼/鲨“卡位”或越界:增大 minMoveDist、收紧 maxTargetDistance,确保越界预测生效。
  • 二次启动丢参数:检查 PlayerPrefs.Save() 是否调用、键名是否一致;必要时清理后重新保存。