AR眼镜菜谱助手:在厨房里飘浮的烹饪指南

0 阅读11分钟

春节那几天,厨房成了家里最热闹的地方。亲戚们围坐一桌,我得在灶台和案板之间来回穿梭,时不时还要掏出手机翻菜谱。手机屏幕小,手上沾着油,划起来费劲,有时候还掉地上。我当时就想,要是能把菜谱直接投影在眼前该多好——抬头看步骤,低头切菜,再也不用摸手机了。

Rokid眼镜正好能实现这个想法。于是趁着假期,我捣鼓了一个眼镜端能用的菜谱查询工具,用Unity写,跑在眼镜上,所有数据本地存,语音一说就跳转到对应菜,还能显示每一步需要等几分钟。这篇文章就记录下完整的开发过程,核心代码贴在后面,需要的朋友直接拿去改。

一、这个菜谱助手能干点啥

先说说最后做出来是什么样:

  • 眼镜视野里飘着一个半透明的面板,上面显示一道菜的菜名,旁边有个小图标。
  • 按R键(模拟语音)或者用语音说“红烧肉怎么做”,面板就会刷出这道菜的详细步骤,同时屏幕上会弹出一个“识别到:红烧肉”的动画,然后步骤逐条显示。
  • 步骤里面如果有等待时间,比如“小火炖45分钟”,就在那一步后面加一行小字“→ 约45分钟后进行下一步”。
  • 按W键切换到下一道菜,按S键回到上一道菜,循环切换。切换后只显示新菜的菜名,想看步骤还得按R。
  • 所有菜谱都存在代码里,想加新菜直接往列表里添。

这个设计思路是:默认只露菜名,不干扰视线;想知道做法了再主动查询,符合厨房里“先看今天做什么,再看怎么做”的习惯。

二、整体架子怎么搭

我习惯把代码拆成几个独立的模块,各管一摊:

  • 数据层(RecipeData) :就是一堆静态菜谱,外加一个根据口语找菜名的方法。不涉及任何逻辑,纯粹数据。

  • 逻辑层(RecipeManager) :一个单例,维护当前选中哪道菜,提供“上一道”“下一道”“语音查询”的接口。它不知道UI长什么样,只会在状态变化时抛出事件。

  • 表现层(RecipeUI) :挂在Canvas上的脚本,监听Manager的事件,更新界面,处理键盘输入(W/S/R)。它可以绑定外部做好的UI元素,也可以自己动态创建一套默认UI。

  • 启动层(ARRecipeBootstrap) :一个静态方法,在场景加载完后自动把上面三个组件创建出来,省得每次手动拖。

这样做的好处是,以后想换一套更炫酷的UI,或者把语音换成按钮,都不需要动Manager和Data,只改UI脚本就行。

三、先把数据定下来:RecipeData

菜谱的数据结构很简单,两步:每一步的文本 + 可选等待分钟数。

[System.Serializable]
public class RecipeStep
{
    public string text;
    public int waitMinutesBeforeNext; // 0表示不提示等待
    public RecipeStep(string stepText, int minutesBeforeNext = 0)
    {
        text = stepText;
        waitMinutesBeforeNext = minutesBeforeNext;
    }
}

整道菜由菜名和步骤数组组成:

[System.Serializable]
public class RecipeEntry
{
    public string dishName;
    public RecipeStep[] steps;
    public RecipeEntry(string name, RecipeStep[] stepList)
    {
        dishName = name;
        steps = stepList ?? new RecipeStep[0];
    }
}

然后我在RecipeData类里放了一个静态列表,存了六道家常菜:

private static readonly List<RecipeEntry> AllRecipes = new List<RecipeEntry>
{
    new RecipeEntry("红烧肉", new RecipeStep[]
    {
        new RecipeStep("五花肉切块,冷水下锅焯水去血沫,水开后约2分钟捞出洗净"),
        new RecipeStep("锅中少油,下冰糖小火炒至枣红色糖色"),
        new RecipeStep("倒入肉块快速翻炒上色,加料酒、生抽、老抽、葱姜、八角、桂皮"),
        new RecipeStep("加开水没过肉,大火烧开转小火,盖上锅盖炖煮", 45),
        new RecipeStep("约45分钟后开盖,大火收汁至浓稠"),
        new RecipeStep("撒葱花即可出锅")
    }),
    new RecipeEntry("番茄炒蛋", new RecipeStep[]
    {
        new RecipeStep("番茄洗净切块,鸡蛋打散加少许盐"),
        new RecipeStep("热油滑蛋,炒至凝固成块后盛出"),
        new RecipeStep("再倒少许油,下番茄块,加盐翻炒出汁", 3),
        new RecipeStep("约3分钟后倒入鸡蛋,翻炒均匀即可")
    }),
    // 宫保鸡丁、麻婆豆腐、青椒肉丝、糖醋排骨省略...
};

每个步骤里的waitMinutesBeforeNext字段后面会用上,在UI里自动转换成提示文字。

最核心的是根据口语找索引的方法。用户可能说“红烧肉怎么做”“怎么做红烧肉”“红烧肉的做法”,甚至只说“红烧肉”,我们都要能识别。

public static int FindIndexByName(string query)
{
    if (string.IsNullOrWhiteSpace(query)) return -1;
    string key = query.Trim()
        .Replace("怎么做", "").Replace("的做法", "").Replace("怎么做才好吃", "")
        .Replace("?", "").Replace("?", "").Trim();
    for (int i = 0; i < AllRecipes.Count; i++)
    {
        if (AllRecipes[i].dishName.Contains(key) || key.Contains(AllRecipes[i].dishName))
            return i;
    }
    return -1;
}

这里用了Contains而不是Equals,因为用户可能只说“红烧肉”,也能命中。去掉“怎么做”等词是为了提高命中率,比如“红烧肉怎么做”处理后只剩“红烧肉”,正好匹配。

四、管状态的管家:RecipeManager

这个脚本是个单例,挂在不会被销毁的物体上。它维护当前菜的索引,并提供几个方法。

先看定义:

public class RecipeManager : MonoBehaviour
{
    public static RecipeManager Instance { get; private set; }
    public int CurrentIndex { get; private set; }
    public RecipeEntry CurrentRecipe => GetRecipeAt(CurrentIndex);

    public event Action<RecipeEntry> OnRecipeChanged;          // 任何原因换菜
    public event Action<RecipeEntry> OnRecipeRecognizedByVoice; // 仅语音识别触发

    void Awake()
    {
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        Instance = this;
        DontDestroyOnLoad(gameObject);
        CurrentIndex = 0;
        OnRecipeChanged?.Invoke(CurrentRecipe);
    }

    public void NextRecipe()
    {
        var list = RecipeData.GetAll();
        if (list.Count == 0) return;
        CurrentIndex = (CurrentIndex + 1) % list.Count;
        OnRecipeChanged?.Invoke(CurrentRecipe);
    }

    public void PreviousRecipe()
    {
        var list = RecipeData.GetAll();
        if (list.Count == 0) return;
        CurrentIndex = (CurrentIndex - 1 + list.Count) % list.Count;
        OnRecipeChanged?.Invoke(CurrentRecipe);
    }

    public void OnVoiceQuery(string speechText)
    {
        int idx = RecipeData.FindIndexByName(speechText);
        if (idx >= 0)
        {
            CurrentIndex = idx;
            OnRecipeRecognizedByVoice?.Invoke(CurrentRecipe);
            OnRecipeChanged?.Invoke(CurrentRecipe);
        }
    }
}

这里有两个事件:OnRecipeChanged是换菜就触发,不管是因为W/S还是语音;OnRecipeRecognizedByVoice只在语音识别成功后触发,用来播放“识别到”动画。之所以分开,是因为动画只需要在语音时出现,手动切换菜时不需要。

OnVoiceQuery就是语音入口。外部(比如语音识别SDK)识别到一段文字,直接传进来,Manager会尝试匹配,匹配成功就更新索引并触发事件。

五、画界面的那个:RecipeUI

这是最复杂的脚本,因为它要处理面板的两种创建方式(绑定现有UI或自建),还要做动画。我先说它怎么用。

如果你已经在场景里摆好了一个Canvas和面板,可以在RecipeUI的Inspector里把panelRoottitleTextstepsTextdishIcon拖进去,它会直接用这些组件。如果你啥也不拖,脚本会在Awake时调用BuildRecipePanel,自己画一套默认面板。

5.1 面板的布局

默认面板是个居中、半透明的深色矩形,宽360高420。顶部是图标(80x80),图标下面是菜名(22号字),再往下是大片区域显示步骤(16号字,换行)。识别动画层是个独立的浮层,平时隐藏,语音识别时显示“识别到:xxx”,做缩放和淡入淡出。

BuildRecipePanel里全是RectTransform的计算,关键是把锚点和偏移量算对。比如面板本体:

_panel = go.AddComponent<RectTransform>();
_panel.anchorMin = new Vector2(0.5f, 0.5f);
_panel.anchorMax = new Vector2(0.5f, 0.5f);
_panel.pivot = new Vector2(0.5f, 0.5f);
_panel.anchoredPosition = Vector2.zero;
_panel.sizeDelta = new Vector2(360f, 420f);

这样面板始终居中。图标放在面板顶部:

iconRect.anchorMin = new Vector2(0.5f, 1f);
iconRect.anchorMax = new Vector2(0.5f, 1f);
iconRect.pivot = new Vector2(0.5f, 1f);
iconRect.anchoredPosition = new Vector2(0f, -16f);

菜名在图标下面:

titleRect.anchorMin = new Vector2(0f, 1f);
titleRect.anchorMax = new Vector2(1f, 1f);
titleRect.pivot = new Vector2(0.5f, 1f);
titleRect.anchoredPosition = new Vector2(0f, -108f);

步骤区域占剩余空间,留出边距:

stepsRect.anchorMin = new Vector2(0f, 0f);
stepsRect.anchorMax = new Vector2(1f, 1f);
stepsRect.offsetMin = new Vector2(16f, 16f);
stepsRect.offsetMax = new Vector2(-16f, -156f);

识别动画层是另一个RectTransform,锚点居中,大小320x80,带一个CanvasGroup控制透明度。

5.2 图标加载

图标放在Assets/Resources/RecipeIcons/下,文件名必须和菜名一致(比如“红烧肉.png”)。代码用Resources.Load加载,如果找不到图,就用一个占位色块,颜色根据菜名关键字来定:

private Sprite LoadRecipeIcon(string dishName)
{
    string path = "RecipeIcons/" + dishName;
    Sprite s = Resources.Load<Sprite>(path);
    if (s != null) return s;
    Texture2D tex = Resources.Load<Texture2D>(path);
    if (tex != null) return Sprite.Create(tex, new Rect(0,0,tex.width,tex.height), new Vector2(0.5f,0.5f));
    return null;
}

占位色块的颜色规则写在一个辅助方法里:

private Color GetPlaceholderColorForDish(string dishName)
{
    if (dishName.Contains("红烧")) return new Color(0.75f,0.35f,0.2f);
    if (dishName.Contains("番茄")) return new Color(0.95f,0.5f,0.2f);
    if (dishName.Contains("鸡")) return new Color(0.85f,0.7f,0.4f);
    if (dishName.Contains("豆腐")) return new Color(0.95f,0.9f,0.85f);
    if (dishName.Contains("青椒")) return new Color(0.4f,0.7f,0.3f);
    if (dishName.Contains("排骨") || dishName.Contains("糖醋")) return new Color(0.8f,0.5f,0.25f);
    return Color.gray;
}

这样没有图的时候也能区分菜品。

5.3 步骤格式化

步骤不是简单拼接,要处理等待时间提示。比如红烧肉第四步是waitMinutesBeforeNext=45,那格式化后应该是:

1. 五花肉切块,冷水下锅焯水去血沫,水开后约2分钟捞出洗净
2. 锅中少油,下冰糖小火炒至枣红色糖色
3. 倒入肉块快速翻炒上色,加料酒、生抽、老抽、葱姜、八角、桂皮
4. 加开水没过肉,大火烧开转小火,盖上锅盖炖煮
   → 约45分钟后进行下一步
5. 约45分钟后开盖,大火收汁至浓稠
6. 撒葱花即可出锅

实现方法:

private string FormatStepsWithTiming(RecipeStep[] steps)
{
    var lines = new System.Collections.Generic.List<string>();
    for (int i = 0; i < steps.Length; i++)
    {
        lines.Add((i + 1) + ". " + steps[i].text);
        if (steps[i].waitMinutesBeforeNext > 0)
            lines.Add("   → 约 " + steps[i].waitMinutesBeforeNext + " 分钟后进行下一步");
    }
    return string.Join("\n", lines);
}

5.4 识别动画

动画用一个计时器控制。当收到OnRecipeRecognizedByVoice事件时,设置_recognitionTimer = recognitionDisplayDuration(默认1.5秒),激活浮层,然后在Update里根据剩余时间计算缩放和透明度:

if (_recognitionTimer > 0f)
{
    _recognitionTimer -= Time.deltaTime;
    float t = 1f - _recognitionTimer / recognitionDisplayDuration;
    float scale, alpha;
    if (t < 0.2f)
    {
        float u = t / 0.2f;
        scale = Mathf.Lerp(0.3f, 1.05f, u);
        alpha = u;
    }
    else if (t > 0.85f)
    {
        float u = (t - 0.85f) / 0.15f;
        scale = Mathf.Lerp(1f, 0.8f, u);
        alpha = 1f - u;
    }
    else
    {
        scale = 1f;
        alpha = 1f;
    }
    _recognitionRoot.localScale = Vector3.one * scale;
    _recognitionCanvasGroup.alpha = alpha;
}

这个三段式动画:前20%时间放大并淡入,中间保持,最后15%缩小并淡出,效果比较柔和。

5.5 按键处理

在Update里监听W、S、R键。W/S切换菜时,先把_stepsVisible设为false(表示不显示步骤),再调Manager的切换方法。R键模拟语音查询,直接调Manager.OnVoiceQuery("红烧肉怎么做")

if (Input.GetKeyDown(KeyCode.W))
{
    _stepsVisible = false;
    RecipeManager.Instance.NextRecipe();
}
if (Input.GetKeyDown(KeyCode.S))
{
    _stepsVisible = false;
    RecipeManager.Instance.PreviousRecipe();
}
if (allowTestVoiceKey && Input.GetKeyDown(KeyCode.R))
    RecipeManager.Instance.OnVoiceQuery("红烧肉怎么做");

注意allowTestVoiceKey是一个bool,可以在Inspector里关闭,避免打包后还响应R键。

5.6 刷新界面

RefreshRecipe方法根据当前的RecipeEntry_stepsVisible来更新文字和图标:

private void RefreshRecipe(RecipeEntry recipe)
{
    if (_titleText != null)
        _titleText.text = recipe != null ? recipe.dishName : "—";
    if (_dishIcon != null)
    {
        Sprite s = recipe != null ? LoadRecipeIcon(recipe.dishName) : null;
        if (s != null) { _dishIcon.sprite = s; _dishIcon.color = Color.white; }
        else { _dishIcon.sprite = null; _dishIcon.color = GetPlaceholderColorForDish(recipe?.dishName); }
    }
    if (_stepsText != null)
    {
        if (!_stepsVisible) _stepsText.text = "";
        else if (recipe == null || recipe.steps == null || recipe.steps.Length == 0) _stepsText.text = "暂无步骤";
        else _stepsText.text = FormatStepsWithTiming(recipe.steps);
    }
}

六、启动引导:ARRecipeBootstrap

每次手动在场景里搭这些太麻烦,所以我写了一个静态初始化方法,用RuntimeInitializeOnLoadMethod在场景加载后自动执行:

public class ARRecipeBootstrap
{
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
    private static void Init()
    {
        if (RecipeManager.Instance != null) return;

        var root = new GameObject("RecipeSystem");
        root.AddComponent<RecipeManager>();

        var canvasGo = new GameObject("RecipeCanvas");
        canvasGo.transform.SetParent(root.transform);
        canvasGo.AddComponent<Canvas>().renderMode = RenderMode.ScreenSpaceOverlay;
        canvasGo.AddComponent<CanvasScaler>();
        canvasGo.AddComponent<GraphicRaycaster>();
        canvasGo.AddComponent<RecipeUI>();
    }
}

这样只要项目里有这个脚本,运行游戏时就会自动创建菜谱系统,不需要任何手动设置。AfterSceneLoad保证在场景加载完之后执行,避免和场景里其他初始化冲突。

七、怎么跑起来

把上面四个脚本放进Unity项目(记得命名要一致),然后在Assets下建一个Resources/RecipeIcons文件夹,往里扔几张图片,比如“红烧肉.png”、“番茄炒蛋.png”……没有图也能跑,就是显示色块。

运行场景,你就能看到屏幕中央出现一个半透明的面板,上面显示第一道菜(默认是红烧肉)的菜名和图标。按R键,面板会刷出步骤,同时弹出“识别到:红烧肉”的动画。按W下一道,按S上一道。

如果想测试语音识别,可以在Update里把R键改成调用OnVoiceQuery并传入不同的文本,比如“我想做番茄炒蛋”,看看能否匹配到。

真实眼镜上,语音识别SDK会把用户说的话转成文本,你只需要在回调里调用RecipeManager.Instance.OnVoiceQuery(识别文本)就行。

八、还能加点啥

这个基础版本已经能用了,但还可以扩展很多:

  • 手势翻页:用眼镜的手势识别来切换上下道,比按W/S更自然。

  • 步骤图:每步配一张示意图,从Resources加载,放在步骤文字旁边。

  • 计时器:点击等待提示,启动一个浮动的倒计时,到点提醒。

  • 多轮语音:用户说“下一步”,自动高亮当前步骤并语音播报。

  • 云端菜谱:从服务器拉取菜谱,动态添加,不用更新应用。

  • AR空间锚点:把菜谱面板固定在厨房某个位置(比如冰箱上),这样每次进厨房它都在那儿。

九、最后说两句

这个菜谱助手从想法到写完代码大概用了两天,大部分时间花在UI布局和动画调试上。数据层和逻辑层其实很简单,只要设计好事件,UI可以随便换。如果你也想在AR眼镜上做个类似的小工具,希望这篇文章能帮你省点时间。

代码全部贴在上面了,直接复制就能用。图标文件夹记得自己建,不想放图也没问题,就是显示色块。祝大家做菜时不再手忙脚乱,新年快乐!

参考平台来自于,SDK来自于