iOS与Unity混合开发中的通用指令化绘制工具设计
在实际 iOS 与 Unity 混合工程里所谓“iOS-Unity 通用绘制工具”通常并不是一个能自动把世界坐标换算成屏幕坐标的“黑盒”而是一套从绘制数据协议、Unity 侧指令封装、iOS 原生渲染到回写链路一起协同的方案。之所以需要单独抽一套通用层是因为大多数项目在 UI 动态画线、涂鸦标记、轨迹预览这些场景里都会遇到同一个问题Unity 侧已经采集到了点序列iOS 原生侧还需要再画一份两端如果各自写一套坐标换算和样式解析等到联调时就会出现线条偏移、触摸事件不同步、横竖屏旋转后错位等问题。这篇文章会围绕“动态画线”这个最小场景介绍一套可落地的通用绘制工具结构。你会看到如何定义两端都认的 JSON 绘制指令如何在 Unity 侧生成噪声曲线、折线和涂鸦数据如何在 iOS 侧用 CAShapeLayer 渲染这些指令并通过桥接方法把原生触摸事件回传给 Unity。工程中不涉及平台破解、抓包或其他灰产场景只讨论正规游戏、渲染工具和原生辅助功能开发。1. 先确定主线这是一套指令化绘制桥接不是大而全的画板 SDK1.1 这种工具通常会出现在哪些业务场景里游戏内玩法或辅助编辑器常见的绘制需求大致可以分成三类玩家用触摸笔迹做涂鸦、签名、关卡内标记。Unity 把业务生成的路径、连击轨迹、噪声波形显示在场景层或 UI 层。iOS 原生层需要在 Unity 渲染结果之上叠加一层批注例如截图标注、AR 引导线、回放控制工具栏。第 1 种和第 2 种在纯 Unity 工程中可以用 LineRenderer 或 UGUI 动态网格完成。真正麻烦的是第 3 种。Unity 的渲染结果和 iOS 原生视图属于两个不同的渲染体系Unity 使用 OpenGL ES 或 Metal 渲染到自己的屏幕区域iOS 原生视图则基于 UIKit、Core Animation。如果业务要求原生侧动态画线而 Unity 侧又要同步同一份数据最稳妥的做法不是截屏下发而是把绘制动作本身抽象成指令让两端执行同一份指令集。1.2 通用绘制工具应该只管理绘制不应该直接管理游戏对象做这类工具时容易犯的最大错误是试图让一条线条直接对应一个 GameObject 或一个 UIView然后用很重的脚本去控制生命周期。真正的通用绘制工具应当更薄接收一个绘制动作集合。将动作转换成统一数据格式。把数据提交给对应渲染端。渲染端只负责画不负责业务逻辑。这里还有一个关键取舍数据协议才是核心渲染实现可以替换。Unity 编辑器里可以用 UGUI 顶点临时预览iOS 原生里可以用 CAShapeLayer 渲染未来如果切换到 Metal 自绘也只需要替换最底层的render(payload:)方法不需要改动上层业务代码。2. 第一层基础设施让 iOS 和 Unity 都理解同一份绘制指令2.1 先用一个可以扩展的 JSON envelope 承载整批操作绘制数据不能只包含单个线条的点数组。实际场景里还包含画布 ID、画布宽高、坐标空间、绘制颜色、线宽、透明度、是否需要清除画布、是否允许原生接收触摸事件等附加信息。建议先定义一个 envelope 结构用来包住整批操作。{ version: 1, canvasId: annotation_canvas, coordinateSpace: uikit_points, viewport: { width: 390, height: 844, scale: 3.0 }, actions: [ { op: stroke, pathId: noise_001, style: { strokeColor: #2277FF, strokeWidth: 4, opacity: 0.9, lineCap: round, lineJoin: round, dashPattern: [] }, points: [ { x: 20, y: 500 }, { x: 60, y: 470 }, { x: 100, y: 420 } ] }, { op: clear } ] }这段 JSON 是两端共同约定的协议。version用来做向后兼容canvasId用来区分多个画布coordinateSpace告诉两端坐标原点在哪里viewport则保留了画布逻辑尺寸方便 iOS 原生侧在屏幕旋转后做映射。actions数组用于绘画动作每个动作都是一条独立可执行指令。2.2 动作类型要按“可回放、可增量追加”设计绘制工具的动作类型不需要一开始就很多但每种动作的语义必须明确不能一个op里既画线又处理手势。建议从最小集开始op 类型用途必需字段说明stroke绘制一条折线或曲线pathId,style,points一条路径只对应一个 CAShapeLayerclear清空画布无可以搭配exceptPathIds实现部分清除layerVisible隐藏或显示某一批路径pathId,visible用于标记线的擦除和恢复enableTouch开启或关闭原生触摸采集enabled避免原生层误接管手势touchiOS 原生把触摸点同步给 Unityphase,point,pointerId这是反向指令在真实工程中stroke不必高频发送整条路径。更合适的策略是 Unity 先在业务层累积点数据用手势结束时再把整条路径发给 iOS。如果确实需要实时预览也可以按帧发送增量点但增量点仍要归属于同一个pathId。这种设计让回放、回滚、撤销都变得简单因为每个操作都是可以重放的纯指令。2.3 坐标系统一是两端联调的第一道门槛iOS 的 UIKit 坐标以左上角为原点x 向右增大y 向下增大。Unity UI 的默认坐标系在某些设置下 y 轴方向并不一致如果直接使用 Unity 的屏幕像素坐标很容易出现上下颠倒。在实际项目中更推荐使用“归一化坐标”或“逻辑点坐标”。协调方式可以这样定iOS 全部使用 UIKit point不写 pixel。Unity 向 iOS 发送点之前先把世界坐标或 UI 坐标换算成 iOS 画布坐标。在 iOS 侧渲染时如果画布尺寸和 JSON 里记录的viewport不一致使用比例换算而不是直接 draw at point。float uiKitX uiX; float uiKitY screenHeightPoints - uiY;这段换算说明的是 Unity 坐标转 iOS 坐标时的典型处理思路。如果两端都使用归一化坐标就是下面这个更稳的公式normalizedX pointX / viewportWidth normalizedY pointY / viewportHeight收到 JSON 的端再用自己的实际画布宽高乘回去就能应对不同分辨率。3. Unity 侧把绘制逻辑收敛成一个命令缓冲器3.1 C# 侧不要到处直接操作 LineRenderer如果项目的业务层到处调用LineRenderer.SetPosition后面想加 iOS 原生画布就会非常痛苦。通用绘制工具的第一步是在 C# 侧做一个命令缓冲器业务层只负责向缓冲器提交点数据和样式。public class DrawingCommandBuffer { private ListDrawingStrokeAction _strokes new ListDrawingStrokeAction(); public void BeginStroke(string pathId) { _strokes.Add(new DrawingStrokeAction { PathId pathId }); } public void AddPoint(float x, float y) { if (_strokes.Count 0) { return; } var current _strokes[_strokes.Count - 1]; current.Points.Add(new DrawingPoint { X x, Y y }); } public void SetStrokeStyle(Color32 color, float width, float opacity) { if (_strokes.Count 0) { return; } var current _strokes[_strokes.Count - 1]; current.StrokeColor ColorUtility.ToHtmlStringRGBA(color); current.StrokeWidth width; current.Opacity opacity; } public void EndStroke() { // 这里可以做点数压缩、抽稀和校验 } public ListDrawingStrokeAction Flush() { var result _strokes; _strokes new ListDrawingStrokeAction(); return result; } }DrawingStrokeAction和DrawingPoint都是可序列化的 DTO不依赖 UnityEngine 的JsonUtility数组序列化限制实际工程可以结合Newtonsoft.Json、System.Text.Json或 Unity 自带序列化器处理。重点在于业务层在代码里只看到“开始画笔、加一个点、设置样式、结束画笔”不需要关心这些点最终是进了 LineRenderer 还是 CAShapeLayer。3.2 一个可运行的最小用例用 PerlinNoise 生成动态波形Mathf.PerlinNoise 经常被用于生成地形高度、云层变化、人物抖动和波形轨迹。在绘制工具里它适合用来演示“Unity 动态生成路径点再交给原生渲染”的完整链路。下面这段代码把噪声值转成一条连续的折线横坐标从画布左侧均匀推进到右侧。public string BuildNoiseStrokePayload( float canvasWidth, float canvasHeight, float seed, int sampleCount) { var buffer new DrawingCommandBuffer(); float startX 20f; float endX canvasWidth - 20f; float baseY canvasHeight * 0.5f; float amplitude canvasHeight * 0.2f; buffer.BeginStroke(noise_ seed.ToString(0.000)); buffer.SetStrokeStyle( new Color32(0x22, 0x77, 0xFF, 0xFF), 4f, 0.9f); for (int i 0; i sampleCount; i) { float t (float)i / sampleCount; float noiseValue Mathf.PerlinNoise(seed t * 6f, 0.5f); float x Mathf.Lerp(startX, endX, t); float y baseY (noiseValue - 0.5f) * 2f * amplitude; buffer.AddPoint(x, y); } buffer.EndStroke(); var payload DrawingPayloadBuilder.BuildJson( annotation_canvas, canvasWidth, canvasHeight, buffer.Flush()); return payload; }这段代码的关键点有三个sampleCount控制点密度点太密会浪费原生渲染开销点太疏则曲线不平滑。入门项目可以先给 200 个点。Mathf.PerlinNoise(seed t * 6f, 0.5f)的第二个参数写固定值得到的是一条一维噪声波形如果第二个参数也随 t 变化得到的是一块噪声面。BuildJson负责把 DTO 列表转成上一节定义的 JSON envelope不参与渲染逻辑。3.3 Unity Editor 内可以先预览再把指令提交给原生如果工程暂时只需要在 iOS 原生侧渲染那么在 Unity Editor 里调试时需要有一个轻量预览路径。预览最简单的方式是用OnDrawGizmos或 Gizmos.DrawLine不引入额外 UI 组件。#if UNITY_EDITOR private ListVector2 _previewPoints; private void OnDrawGizmosSelected() { if (_previewPoints null || _previewPoints.Count 2) { return; } Gizmos.color Color.blue; for (int i 0; i _previewPoints.Count - 1; i) { Gizmos.DrawLine( new Vector2(_previewPoints[i].x, _previewPoints[i].y), new Vector2(_previewPoints[i 1].x, _previewPoints[i 1].y)); } } #endif如果你用的是 UGUI 动态画线也不建议一开始就直接写自定义网格。先用少量管理类把“路径数据”保存下来再用LineRenderer或UGUI做编辑器预览等到数据协议稳定后再把真正发布到 iOS 的渲染实现切到原生层。编辑器预览和 iOS 原生渲染可以并存预览用于快速检查原生渲染用于真机验证。4. iOS 原生侧用 CAShapeLayer 渲染指令用 Auto Layout 管理工具栏4.1 用 Codable 解析上一节下发的 JSONiOS 原生侧应当有一个专用绘制视图不建议直接在主ViewController里写绘制逻辑。绘制视图负责解析 JSON、管理 CAShapeLayer、处理触摸事件ViewController 只负责布局。可以先用 Codable 定义结构struct DrawingEnvelope: Codable { let version: Int let canvasId: String? let coordinateSpace: String let viewport: DrawingViewport? let actions: [DrawingNativeAction] } struct DrawingViewport: Codable { let width: CGFloat let height: CGFloat let scale: CGFloat? } struct DrawingNativeAction: Codable { let op: String let pathId: String? let style: DrawingStrokeStyle? let points: [DrawingPoint]? let visible: Bool? let enabled: Bool? let phase: String? } struct DrawingStrokeStyle: Codable { let strokeColor: String? let strokeWidth: CGFloat? let opacity: CGFloat? let lineCap: String? let lineJoin: String? let dashPattern: [CGFloat]? } struct DrawingPoint: Codable { let x: CGFloat let y: CGFloat }解析完成后通过op分发到不同处理函数。核心逻辑是把stroke动作渲染成一个CAShapeLayer。func render(envelope: DrawingEnvelope) { if let viewport envelope.viewport { self.viewport viewport } for action in envelope.actions { switch action.op { case stroke: renderStroke(action) case clear: layer.sublayers?.removeAll() case layerVisible: updateLayerVisibility(action) case enableTouch: isTouchEnabled action.enabled ?? false default: break } } }CAShapeLayer直接使用 Core Animation 渲染矢量路径不涉及额外贴图对于画线工具来说比频繁提交纹理更高效也更容易做颜色、圆角、线帽和虚线控制。4.2 绘制 stroke 时要注意坐标映射和图层命名CAShapeLayer和UIBezierPath是 iOS 原生绘制线的常用组合。下面给出一个基础实现思路private func renderStroke(_ action: DrawingNativeAction) { guard let points action.points, points.count 2 else { return } guard let bezierPath UIBezierPath() else { return } let firstPoint mapRenderPoint(points[0]) bezierPath.move(to: firstPoint) for point in points.dropFirst() { let nextPoint mapRenderPoint(point) bezierPath.addLine(to: nextPoint) } let shape CAShapeLayer() shape.path bezierPath.cgPath shape.fillColor UIColor.clear.cgColor shape.strokeColor color(from: action.style?.strokeColor).cgColor shape.lineWidth action.style?.strokeWidth ?? 2 shape.opacity Float(action.style?.opacity ?? 1) shape.lineCap .round shape.lineJoin .round if let dashPattern action.style?.dashPattern { shape.lineDashPattern dashPattern.map { NSNumber(value: Double($0)) } } if let pathId action.pathId { layer.addSublayer(shape) shape.name pathId } else { layer.addSublayer(shape) } } private func mapRenderPoint(_ point: DrawingPoint) - CGPoint { let logicalW viewport?.width ?? bounds.width let logicalH viewport?.height ?? bounds.height let normalizedX point.x / logicalW let normalizedY point.y / logicalH return CGPoint( x: normalizedX * bounds.width, y: normalizedY * bounds.height ) }如果下发时已经约定好 iOS 坐标是“左上角原点”这里就不需要反转 y。反之如果 Unity 发送的是从下到上的坐标就需要在mapRenderPoint里补一句y bounds.height - y。注意不要把CAShapeLayer的frame当成业务状态去维护。frame只决定 layer 在其父坐标系里的位置一般绘制坐标都应由path本身携带图层 frame 保持和画布对齐。4.3 使用 UIStackView 和 Auto Layout 搭建绘制工具栏绘制工程里经常需要一组颜色选择器、画笔宽度滑块、撤销按钮。这些控件如果用 Frame 硬编码横竖屏切换时需要一个一个改坐标。用UIStackView配合 Auto Layout可以在一开始就避免这种布局问题。private lazy var toolbarStackView: UIStackView { let stack UIStackView() stack.axis .horizontal stack.alignment .center stack.distribution .equalSpacing stack.spacing 12 stack.translatesAutoresizingMaskIntoConstraints false return stack }() override func viewDidLoad() { super.viewDidLoad() view.addSubview(canvasView) view.addSubview(toolbarStackView) let colorButton UIButton(type: .system) colorButton.setTitle(颜色, for: .normal) let widthSlider UISlider() widthSlider.minimumValue 1 widthSlider.maximumValue 20 let clearButton UIButton(type: .system) clearButton.setTitle(清空, for: .normal) toolbarStackView.addArrangedSubview(colorButton) toolbarStackView.addArrangedSubview(widthSlider) toolbarStackView.addArrangedSubview(clearButton) NSLayoutConstraint.activate([ toolbarStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), toolbarStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16), toolbarStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), canvasView.leadingAnchor.constraint(equalTo: view.leadingAnchor), canvasView.trailingAnchor.constraint(equalTo: view.trailingAnchor), canvasView.topAnchor.constraint(equalTo: toolbarStackView.bottomAnchor, constant: 8), canvasView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) ]) }这种结构里canvasView负责整块绘制区域工具栏stackView悬浮在上方。由于绘制工具往往需要占用比较多屏幕面积所以要让canvasView具有明确的 leading、trailing、top、bottom 四边约束不要只给 width 和 height。5. 端到端链路从 Unity 主动推指令到 iOS 原生回传触摸事件5.1 Unity 调用 iOS 原生方法的桥接层Unity 工程打 iOS 包后C# 可以直接调用工程里的 Objective-C/C/C 函数。前提是原生侧实现了同名导出函数并且 C# 使用__Internal标记。#if UNITY_IOS !UNITY_EDITOR using System.Runtime.InteropServices; public static class DrawingNativeBridge { [DllImport(__Internal)] private static extern void UnityDrawingBridgeSend(string payload); public static void Send(string payload) { UnityDrawingBridgeSend(payload); } } #endif在 Unity 业务代码里调用时要区分 Editor 和 iOS 真机环境。Editor 里不能直接调用__Internal函数否则会因为找不到入口导致崩溃。public void FlushToNative(string payload) { #if UNITY_IOS !UNITY_EDITOR DrawingNativeBridge.Send(payload); #else Debug.Log([Drawing] preview only, payload size payload.Length); #endif }5.2 iOS 原生导出函数和回传 Unity原生导出的 C 函数接收到字符串后要切换到主线程再交给绘制视图。不要在回调函数里直接操作 UIKit 对象。extern C { void UnityDrawingBridgeSend(const char *jsonString) { if (jsonString NULL) return; NSString *json [NSString stringWithUTF8String:jsonString]; dispatch_async(dispatch_get_main_queue(), ^{ [[NSNotificationCenter defaultCenter] postNotificationName:DrawingPayloadDidReceive object:json]; }); } }iOS 原生侧在touchesBegan、touchesMoved、touchesEnded中采集触摸点后可以通过UnitySendMessage回传给 Unity 场景里的某个 GameObject。extern C { void UnityDrawingBridgeTouchEvent(const char *eventJson) { UnitySendMessage(DrawingMain, OnNativeTouchEvent, eventJson); } }C# 侧对应写一个普通方法public class DrawingMain : MonoBehaviour { public void OnNativeTouchEvent(string payload) { var touchData JsonUtility.FromJsonNativeTouchEvent(payload); // 根据 touchData.phase 处理 down、move、up } }这个回路的价值在于Unity 只负责下发热点指令原生侧负责绘图和手势两端不会互相阻塞。5.3 真机验证怎样的输出才算链路打通搭建完桥接后不要只看画面是否出现应该按下面的顺序验证在 Unity 场景里放一个“生成噪声线”按钮。点击按钮后Unity 组包并调用FlushToNative。iOS 原生 Xcode 控制台应输出收到 JSON 的日志。绘制视图上出现蓝色噪声线。在 iOS 绘图层用手指滑动Xcode 控制台应输出 touch 事件。Unity Console 中应该能收到OnNativeTouchEvent回调。排查链路可以按下表逐步查看验证点预期现象如果失败优先检查Unity 按钮点击日志出现 payload 长度C# 里是否走了UNITY_IOS分支原生收到 JSONXcode 打印收到字符串导出函数名是否一致JSON 是否 NULL视图绘制成功蓝色噪声线出现JSON 里coordinateSpace和坐标映射触摸采集成功iOS 日志输出触摸点isTouchEnabled是否为 trueUnity 收到回传Unity 日志显示 touch phaseUnitySendMessage的对象名是否真实存在6. 这几个坑最容易在 iOS-Unity 绘制链路里出现6.1 横竖屏旋转后线条位置错乱很多绘制工具一开始就锁定竖屏所以横竖屏来回切换时才暴露出坐标系问题。CAShapeLayer的path不会自动跟随 view 的 size 变化重新计算尤其是你已经保存了绝对坐标而不是归一化坐标。排查方法确认 JSON 下发时是否携带了当时的viewport。确认mapRenderPoint是否使用当前 bounds 反算。旋转前后如果画布尺寸发生变化需要重新触发整批指令的重新渲染。最稳妥的做法是在 iOS 原生侧保留最后一份DrawingEnvelope在layoutSubviews或viewDidLayoutSubviews中重新执行一次render(envelope:)。但如果每次都新建 CAShapeLayer内存会涨得很快性能不好所以生产项目偏向于保存 path 列表在旋转后重建。6.2 动态画线过于频繁最终导致卡顿或内存报警每次touchesMoved都生成新的CAShapeLayer是画板类项目最常见的错误。一条手指路径可能产生几百个 move 回调首帧点创建好 layer 后后续点只需要调用UIBezierPath.addLine并更新同一个 layer 的path。不要把每个点都提交一个大 JSON。更推荐的做法是手势 down 时创建一条新 stroke。move 时把点追加到内存并且定时批量渲染。up 时才把完整 stroke 发送给 Unity 或保存到回放列表。动态线回放阶段则使用 CADisplayLink每帧推进有限个点。性能排查顺序建议第一看 CAShapeLayer 是否过多第二看是否频繁调用layer.path 赋值第三看是否把整条路径 JSON 高频发送到日志或文本控件里。6.3 iOS 真机上出现 DllNotFoundException: Unable to load DLL slua如果 Unity 工程集成了 slua 这类 Lua 桥接插件打包到 iOS 真机后控制台可能会看到类似下面这一条DllNotFoundException: Unable to load DLL slua这条报错不能从 C# 调用方单独解决。它通常表示插件没有以 iOS 支持的 native library 形式打进包里或者插件只导入了 Editor 版本。检查方式如下查看插件的 iOS 版本目录是否包含.a、.framework或.bundle文件。在 Unity 插件 Import Settings 中确认 Platform 勾选了 iOS。真机日志无法在 Editor 里复现所以要先确认你用 Xcode 构建的是真机包而不是模拟器包。跑一个只调用 slua 的极简用例排除业务代码干扰。如果输入数据里还有 Lua 侧动态生成的绘制点要注意点坐标在进入 C# 后仍然是 Lua 表里的数字必须先在 C# 侧转换成 DrawingPoint DTO再进行 JSON 序列化不能直接把 Lua 表格转成 JSON 后混进 Native 绘制协议。6.4 渲染 API 或插件不统一导致构建期现象不可复现Unity 在导出 iOS Xcode 工程时渲染 API 可以在 Player Settings 里勾选。如果关闭 Auto Graphics API只留 iOS 支持的 Metal 渲染很多绘制相关的异常更容易复现。可以进入 Unity 的 Player Settings在 Other Settings 的 Rendering 部分手动确认Graphics APIs: Metal Auto Graphics API: false注意这并不会直接解决 DllNotFoundException但统一渲染 API 之后纹理、截图和动态绘制相关的日志会更稳定排查问题时也少一个变量。7. 生产环境下如何继续演进这套通用绘制工具7.1 性能和内存优化要从“减少指令和图层数量”入手在开发环境跑通不代表能直接用于生产。上线或对外发布前至少要做一轮绘制性能检查用几百条动态线测一测 CAShapeLayer 数量对内存的影响。在性能面板统计 Unity 侧 JSON 序列化耗时。在 iOS Instrument 里观察 Core Animation 的 Commit 数量。如果使用小图标、颜色点等贴图资源Unity 侧优先考虑 Sprite Atlas 打包合图减少小图各自提交造成的多次内存拷贝和额外 Draw Call。下面这张表可以作为性能验收清单项目开发环境关注点生产环境关注点JSON 格式可读性好方便打印可压缩频率低于每帧多次指令数量越小越好按帧批量下发避免每个点一次调用CAShapeLayer一个 stroke 一个 layer定期清理无引用图层或复用 path点序列完整保留方便调试增加抽稀算法保留视觉关键点图标资源单图测试Sprite Atlas 合批减少单独加载横竖屏锁竖屏测试记录最后 envelope旋转后重放7.2 日志、监控和回滚机制要跟着协议一起扩展通用绘制工具最大的优势是“指令可回放”生产环境要充分利用这个特性。用户反馈线条丢失或错位时你可以让客户端只上传最近一段动作指令而不是上传整张截图。回放动作指令可以快速判断问题是出在采集端、协议端还是渲染端。日志记录要覆盖四条链路Unity 初始化绘制指令的耗时。Unity 发送 payload 的时间点。iOS 收到 payload 和渲染完成的间隔。iOS 触摸事件回传给 Unity 是否成功。如果生产端需要支持撤销、重做可以给每条动作增加一个本地自增序号或时间戳而不是用随机字符串作为 pathId。带有序号的指令流天然支持服务端合并和客户端回放。7.3 给新项目团队的落地建议第一次接入这套方案时不建议直接铺开涂鸦、标注、AR 引导等全部场景。建议按下面这个顺序做增量验证先实现 Unity 到 iOS 的单向画线只画一条静态 Bezier 折线。再接入 PerlinNoise 生成动态波形验证点序列变化。然后接入 iOS 原生触摸回传验证 Unity 能收到手势事件。最后再把颜色选择器、线宽控制、清空、撤销这些 UI 控件接入 UIStackView 工具栏。每一步都建立一个可运行版本再进入下一步。通用绘制工具真正要解决的不是“有没有画出来”而是当 Unity 侧和 iOS 原生侧都需要理解同一段用户操作时能不能用同一种指令语言干净地表达出来。只要协议稳定、坐标系统一、图层管理清晰后续再加曲线插值、橡皮擦、自定义笔刷、多端回放都是在同一个骨架上扩展而已。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →