简介:这份资源面向Unity开发者与AR互动应用爱好者,聚焦增强现实与实时涂色结合的实践方案,帮助读者理解如何借助EasyAR等插件完成图像识别、目标跟踪与虚拟上色,适合具备一定Unity基础、希望切入AR互动娱乐场景的中级开发者。压缩包共545个文件,约35.3MB,以cs脚本、meta配置、asset资源、prefab预制体、mat材质、shader着色器及dll插件为主,另含少量png贴图、xml说明与java、so等平台相关文件,覆盖工程脚本、场景资源与依赖库等模块。目前已有1491人学习下载。资源围绕AR涂色完整流程展开,涉及AR环境搭建、图像目标准备、涂色对象创建、触摸输入与实时上色、颜色选择与撤销重做等体验优化,以及多设备测试与发布思路,便于读者对照工程结构梳理实现路径,快速搭建可运行的AR涂色原型。
1. AR 涂色为什么在 Unity 里值得单独做一套方案
去年接了个儿童互动绘本的项目,需求方张口就是「手机扫一下卡片,线稿自己上色」。听起来像滤镜,实际做起来才发现,AR 涂色和普通贴图换色完全是两码事:摄像头在动、卡片在动、光照在变,用户手指还在屏幕上乱涂,任何一环没处理好,涂出来的颜色就会「飘」在线稿外面,或者干脆糊成一团。Unity 做 AR 涂色的核心,是把「识别一张线稿图 → 建立屏幕坐标到纹理坐标的映射 → 让笔刷落在正确的 UV 位置 → 实时回写并渲染」这条链路打通。它适合做儿童教育、文创互动、线下展陈这类需要「实体卡片 + 手机屏幕」联动的场景,也适合想入门 AR 内容开发但不想一上来就啃 SLAM 的工程师。这一章先把这件事的边界讲清楚,后面几章再拆实现。
2. 从识别到上色:AR 涂色的技术链路怎么搭
2.1 图像识别与坐标映射的基本原理
AR 涂色的第一步不是涂色,是让程序知道「线稿在屏幕的哪个位置、朝向如何、缩放多少」。常见做法是用 AR 底层的图像识别能力,把一张预设的线稿图作为识别目标,识别成功后返回一个位姿(位置 + 旋转),Unity 再把这个位姿挂到一个平面或 Quad 上。这个 Quad 就是后续所有涂色操作的「画布」。
关键点在于:用户的触摸发生在屏幕空间,而涂色要写进纹理空间。两者之间差了一个「屏幕 → 识别平面 → 纹理 UV」的变换链。很多新手直接拿屏幕坐标当 UV 用,结果就是卡片稍微倾斜,笔刷就偏到姥姥家。正确做法是每帧用射线从摄像机穿过触摸点,和识别平面求交,拿到平面上的局部坐标,再归一化成 0~1 的 UV。
这里有个容易被忽略的细节:识别平面的大小要和线稿纹理的宽高比一致。如果平面是 1:1 而纹理是 4:3,归一化后的 UV 会在某个方向被拉伸,涂出来的圆会变成椭圆。我一般会在初始化时按纹理尺寸动态设置 Quad 的 scale,保证 UV 不被非均匀缩放。
2.2 在 Unity 里跑通最小可涂色 Demo
下面这段代码是整条链路的最小骨架:识别到图之后创建画布、处理触摸、把屏幕点转成 UV、往一张 RenderTexture 上画点。先跑通「点一下出一个色块」,再谈笔刷形状和撤销。
using UnityEngine; public class ARColoring : MonoBehaviour { // 识别成功后挂载的平面,作为涂色画布 public Transform colorPlane; // 线稿纹理,宽高比决定画布比例 public Texture2D lineArt; // 运行时生成的涂色纹理 private RenderTexture paintRT; private Material paintMat; void Start() { // 按线稿宽高比设置画布缩放,避免 UV 拉伸 float aspect = (float)lineArt.width / lineArt.height; colorPlane.localScale = new Vector3(aspect, 1f, 1f); // 创建与线稿同分辨率的 RenderTexture paintRT = new RenderTexture(lineArt.width, lineArt.height, 0); paintRT.Create(); paintMat = GetComponent<Renderer>().material; paintMat.mainTexture = paintRT; // 清空为白色底 RenderTexture.active = paintRT; GL.Clear(true, true, Color.white); RenderTexture.active = null; } void Update() { if (Input.touchCount == 0) return; Touch t = Input.GetTouch(0); if (t.phase != TouchPhase.Began && t.phase != TouchPhase.Moved) return; // 从摄像机穿过触摸点发射射线 Ray ray = Camera.main.ScreenPointToRay(t.position); RaycastHit hit; if (Physics.Raycast(ray, out hit) && hit.transform == colorPlane) { // 把命中点转成画布局部坐标,再归一化为 UV Vector3 local = colorPlane.InverseTransformPoint(hit.point); float u = local.x + 0.5f; float v = local.y + 0.5f; PaintAt(u, v, Color.red, 20); } } // 在指定 UV 处画一个圆形色块 void PaintAt(float u, float v, Color color, int radius) { int cx = Mathf.RoundToInt(u * paintRT.width); int cy = Mathf.RoundToInt(v * paintRT.height); RenderTexture.active = paintRT; GL.PushMatrix(); GL.LoadPixelMatrix(0, paintRT.width, paintRT.height, 0); // 用 GL 画一个实心圆,实际项目可换成笔刷纹理 DrawCircle(cx, cy, radius, color); GL.PopMatrix(); RenderTexture.active = null; } void DrawCircle(int cx, int cy, int r, Color c) { // 简化实现:逐像素判断距离,生产环境建议用 CommandBuffer 或笔刷贴图 Texture2D tmp = new Texture2D(paintRT.width, paintRT.height, TextureFormat.RGBA32, false); RenderTexture.active = paintRT; tmp.ReadPixels(new Rect(0, 0, paintRT.width, paintRT.height), 0, 0); tmp.Apply(); for (int x = cx - r; x <= cx + r; x++) { for (int y = cy - r; y <= cy + r; y++) { if (x < 0 || y < 0 || x >= tmp.width || y >= tmp.height) continue; if ((x - cx) * (x - cx) + (y - cy) * (y - cy) <= r * r) tmp.SetPixel(x, y, c); } } tmp.Apply(); Graphics.Blit(tmp, paintRT); RenderTexture.active = null; Destroy(tmp); } }逻辑上分三段:Start负责按线稿比例建画布和 RenderTexture;Update负责把触摸射线打到平面上并算出 UV;PaintAt负责往纹理写像素。参数里radius控制笔刷粗细,单位是纹理像素,纹理分辨率越高,同样的 radius 看起来越细。colorPlane.localScale那行是防 UV 拉伸的关键,别省。
提示:上面
DrawCircle为了讲清楚原理用了逐像素读写,实际项目里每帧这么干会卡。生产环境应该用一张圆形笔刷贴图配合Graphics.Blit或CommandBuffer做混合,性能差一个数量级。
2.3 笔刷、撤销与图层该怎么组织
最小 Demo 跑通后,真正决定手感的是笔刷系统和撤销机制。笔刷方面,常见做法是准备一组笔刷纹理(圆头、方头、喷枪),涂色时用Graphics.Blit把笔刷按 UV 位置混合到 RenderTexture 上,而不是逐像素画。混合模式用Blend SrcAlpha OneMinusSrcAlpha,这样笔刷边缘的透明度能自然过渡。
撤销这块,血泪经验是别去存整张纹理的快照。一张 2048×2048 的 RGBA 纹理就是 16MB,存十步就爆内存。我一般用「操作栈」:每次落笔记录笔刷类型、UV 坐标、半径、颜色,撤销时重放前面的操作到一张干净的纹理上。操作数少的时候重放很快,操作多了可以每隔 N 步存一张快照做检查点,兼顾内存和速度。
图层方面,如果需求只是「在线稿上涂色」,一张纹理就够。但如果要支持「涂完还能擦、还能叠加不同颜色不互相污染」,就得分层:底色层、涂色层、线稿层,最后在 Shader 里按顺序合成。线稿层始终在最上面,用它的 alpha 做遮罩,这样颜色永远不会盖住线稿的黑边,视觉上才像「涂在线稿里」。
3. 让涂色跟手:Shader 合成与性能调优
3.1 线稿遮罩与颜色合成的 Shader 写法
涂色好不好看,一半靠 Shader。核心思路是:线稿纹理的深色像素是「墙」,颜色只能填在浅色区域。用线稿的亮度做插值因子,亮的地方显示涂色,暗的地方保留线稿。
Shader "Custom/ARColoring" { Properties { _MainTex ("Paint Layer", 2D) = "white" {} _LineArt ("Line Art", 2D) = "white" {} _LineThreshold ("Line Threshold", Range(0,1)) = 0.3 } SubShader { Tags { "RenderType"="Opaque" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; sampler2D _LineArt; float _LineThreshold; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; }; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag(v2f i) : SV_Target { fixed4 paint = tex2D(_MainTex, i.uv); fixed4 line = tex2D(_LineArt, i.uv); // 线稿越暗,越保留线稿;越亮,越显示涂色 float mask = smoothstep(0.0, _LineThreshold, line.r); fixed3 col = lerp(line.rgb, paint.rgb, mask); return fixed4(col, 1.0); } ENDCG } } }_LineThreshold是唯一需要调的参数:调大,线稿的「墙」变厚,颜色不容易溢出但可涂区域变小;调小则相反。一般 0.25~0.4 之间试。smoothstep是为了让边缘过渡自然,直接step会有锯齿。
3.2 移动端帧率与发热的取舍
AR 本身就在跑摄像头和识别,涂色再叠一层实时纹理写入,移动端很容易掉帧发热。几个实测有效的做法:涂色纹理分辨率别超过 1024×1024,除非线稿细节真的需要;笔刷混合用 GPU 的Graphics.Blit,别在 CPU 上逐像素;识别成功后如果卡片静止,可以降频识别,把算力让给渲染。
还有个玄学问题:某些机型上 RenderTexture 的antiAliasing设了反而更卡,因为移动端 GPU 对 MSAA 的支持参差不齐。我一般设成 1,靠笔刷纹理本身的柔边来抗锯齿,效果够用还省性能。发热控制上,把Application.targetFrameRate锁到 30,AR 涂色这种场景 30 帧完全够,锁帧后机身温度能降好几度。
4. 避坑:AR 涂色落地时最容易翻车的 5 个点
现象:涂色位置整体偏移,卡片一动偏得更多。原因:UV 计算用了屏幕坐标或世界坐标,没做「屏幕 → 平面 → 局部坐标」的完整变换。 解决:坚持用射线求交拿平面局部坐标,再归一化。检查colorPlane的 pivot 是否在中心,不在中心的话local.x + 0.5f这个偏移量要跟着改。
现象:涂出来的圆是椭圆,或者笔刷被拉长。原因:画布 Quad 的宽高比和线稿纹理不一致,UV 被非均匀缩放。 解决:初始化时按lineArt.width / lineArt.height设置localScale,纹理换了比例,画布也要跟着换。
现象:涂色盖住了线稿黑边,画面糊成一坨。原因:合成时没做线稿遮罩,或者遮罩阈值设得太小。 解决:用线稿亮度做lerp因子,_LineThreshold从 0.3 起调,边调边看黑边是否清晰。
现象:涂了几十笔之后明显卡顿,内存一路涨。原因:撤销栈存了整张纹理快照,或者每笔都new Texture2D没释放。 解决:撤销改成存操作参数、重放;临时纹理用完Destroy;RenderTexture 在OnDestroy里Release。
现象:部分安卓机识别成功但画面全黑。原因:摄像头权限没给,或者 RenderTexture 创建失败没检查返回值。 解决:启动时显式请求相机权限,paintRT.Create()后判断paintRT.IsCreated(),失败就回退到普通 Texture2D 方案。
5. 进阶:把涂色结果导出成可分享的图片
做到这一步,基本功能已经能交付了。但真正让项目「值钱」的,是涂完能导出、能分享。用户涂了半天,你告诉他「关掉就没了」,体验直接崩。导出本身不难,难的是导出时要把线稿、涂色层、可能的背景按正确顺序合成,还要处理分辨率。
// 把线稿和涂色层合成为一张可保存的 Texture2D public Texture2D ExportResult(Texture2D lineArt, RenderTexture paintRT, int exportWidth = 1024) { int h = Mathf.RoundToInt(exportWidth * (float)lineArt.height / lineArt.width); RenderTexture tmp = RenderTexture.GetTemporary(exportWidth, h, 0); // 用合成 Shader 把两层画到一起 Graphics.Blit(paintRT, tmp, paintMat); RenderTexture.active = tmp; Texture2D result = new Texture2D(exportWidth, h, TextureFormat.RGBA32, false); result.ReadPixels(new Rect(0, 0, exportWidth, h), 0, 0); result.Apply(); RenderTexture.active = null; RenderTexture.ReleaseTemporary(tmp); return result; }导出分辨率我一般给 1024 或 2048,再高对分享没意义还费内存。Graphics.Blit那步用的是前面合成 Shader 的材质,保证导出图和屏幕上看到的一致。导出后EncodeToPNG存本地或上传,就是完整的分享链路。
验证导出是否正确,有个笨但有效的办法:导出后立刻把图片读回来,和屏幕截图做逐像素对比,差异超过阈值就说明合成顺序或阈值参数有问题。我吃过一次亏,导出图里线稿没了,查了半天发现是paintMat在导出时被别的逻辑改了_LineArt引用。从那以后我养成了习惯:导出用的材质单独实例化一份,不和渲染用的共享。这个方案值不值得做,我的判断是——只要你的 AR 场景需要用户「留下点什么」,导出就是必做项,它把一次性的互动变成了可传播的内容。希望帮到你。
本文还有配套的精品资源,点击获取