用了photoshop那么久,从来没仔细想过它到底用了哪些算法。想一想就觉得倒抽一口凉气。
传闻photoshop的创始人,和wps创始人一样,就一个程序员写好了这第一版的成品。同样是做人,咋就差距这么大呢?
千古疑问
- 所以这个.psd文件的格式是什么,它和png、jpg、gif,甚至json文件的格式上有什么区别?
- .psd文件是怎么做到分层的,分层的存储方式又是啥?
- 如何把.psd文件的单层图另外存储起来成为多个sprite?(没错就是Unity中的sprite)以及如何对其进行轮廓检测呢?(unity是如何做到的呢?)
在危险的ps插件边缘试探
作为一个见得不够多脑子也不够灵活的菜鸡,我决定 “旁征博引” “凿壁偷光”,先康康有哪些源码可以让我学习学习~这要从psd文件导入unity的一个插件说起!
我们把一个很神秘的文件放进了photoshop的这个generator路径下,那么这个文件里面都有啥呢?
(JavaScript) main.jsx
(function() {"use strict";// *** PACKAGES ***var fs = require("fs");var path = require("path");var pluginPackage = require("./package.json");// *** CONSTANTS ***var MENU_ID = pluginPackage.name; // our plugin's menu idvar MENU_STRING = "Ps2D Map"; // our plugin's menu textvar PLUGIN_VERSION = pluginPackage.version; // our plugin versionvar PLUGIN_NAME = pluginPackage.name; // plugin namevar MAP_EXTENSION = ".ps2dmap.json"; // map's extension// *** STATE VARIABLES ***var _generator = null; // the generator frameworkvar _currentDocumentId = null; // the document's idvar _currentDocumentFile = null; // the document's filename// *** PLUGIN FUNCTIONALITY// kick it off!function run() {// request the document from photoshop_generator.getDocumentInfo(_currentDocumentId).then(function(document) {_currentDocumentFile = document.file;var layoutDocument = createLayoutDocument(document);saveMapFile(layoutDocument);popup("The Ps2D map file has been created. Hurray!");}).done();}// Get the full filename for the layout file we create.// It will be written in the same directory as the PSD file.function layoutFilename() {var myExtension = path.extname(_currentDocumentFile);var myBasename = path.basename(_currentDocumentFile, myExtension);var myPath = path.dirname(_currentDocumentFile);return myPath + path.sep + myBasename + MAP_EXTENSION;}// save the layout to the file systemfunction saveMapFile(layoutDocument) {// get the filenamevar filename = layoutFilename();// serialize our layoutDocument to a stringvar contents = JSON.stringify(layoutDocument);try{// a synchronous node.js call? finally, sanity!!! just kidding. mostly.fs.writeFileSync(filename, contents);} catch (err){// TODO: It'd be nice to show people a message instead of just logginglog("unable to save map file to " + filename);}}// convert the photoshop document to our own layout documentfunction createLayoutDocument(document) {// create our own documentvar layoutDocument = {};layoutDocument.pluginVersion = PLUGIN_VERSION;layoutDocument.bounds = document.bounds;layoutDocument.mask = document.mask;layoutDocument.sprites = convertLayersToSprites(document.layers);return layoutDocument;}// convert a list of layers into a list of spritesfunction convertLayersToSprites(layers) {// sanityif (layers === null || layers === undefined || layers.length === 0)return null;// sprites go herevar sprites = [];// go through each layerfor (var i = 0, z = layers.length; i < z; i++){var layer = layers[i];// convert itvar sprite = convertLayerToSprite(layer);// add it to the list if it's legitif (sprite !== null) {sprites.push(sprite);}}return sprites;}// convert a ps layer to a spritefunction convertLayerToSprite(layer) {// safetyif (layer === null || layer === undefined) return null;// grab what we need for our mapvar sprite = {};sprite.id = layer.id;sprite.name = layer.name;sprite.bounds = layer.bounds;sprite.mask = layer.mask;sprite.visible = layer.visible;sprite.sprites = convertLayersToSprites(layer.layers);return sprite;}// *** EVENT HANDLERS ***// fires when we've installed successfullyfunction handleMenuItemInstallSuccess() {}// fires when PS can't install our menu itemfunction handleMenuItemInstallFailed() {log("Unable to create Ps2D menu item.");}// the user has clicked our menu itemfunction handleGeneratorMenuClicked(e) {// which menu item caused this event?var menu = e.generatorMenuChanged;// if it wasn't us, jet.if (!menu || menu.name !== MENU_ID) return;// ok, it's go time!run();}// the photoshop document has changedfunction handleCurrentDocumentChanged(id) {_currentDocumentId = id;}// *** UTILITIES ***// write a log messagefunction log(s) {console.log("[" + PLUGIN_NAME + "] " + s);}// show a popup messagefunction popup(msg) {var js = "alert('" + msg + "');"sendJavascript(js);}// run some javascriptfunction sendJavascript(str){_generator.evaluateJSXString(str).then(function(result){console.log(result);},function(err){console.log(err);});}// *** INITIALIZATION ***// main entry point for the pluginfunction init(generator) {// remember this generator in our scope_generator = generator;// install the menu item_generator.addMenuItem(MENU_ID, MENU_STRING, true, false).then(handleMenuItemInstallSuccess, handleMenuItemInstallFailed);// subscribe to PS events_generator.onPhotoshopEvent("generatorMenuChanged", handleGeneratorMenuClicked);}// make our entry point availableexports.init = init;}());
(json package.json)
{"name": "Ps2D","main": "main.jsx","version": "1.0.0","generator-core-version": ">=2.0.2","author": "Steve Kellock"
}
读者看懂了没有,作为一个业余js但是有着“深厚”c++功底的吹牛逼选手,我认为以上这个js代码要表达的思想很简单:
ps给了layers接口,这边负责用一个sprite类接住了layer类的一系列成员变量,包括id、name、bounds、mask、visible,然后把这些东西存成一个json格式的文件~就酱。好,那么我们做个试验,看看这个存成xx.ps2dmap的文件内容都有点啥:
xx.ps2dmap
{"pluginVersion":"1.0.0","bounds":{"top":0,"left":0,"bottom":1820,"right":788},"sprites":[{"id":12,"name":"head","bounds":{"top":55,"left":242,"bottom":357,"right":478},"visible":true,"sprites":null},{"id":15,"name":"neck","bounds":{"top":352,"left":280,"bottom":414,"right":436},"visible":true,"sprites":null},{"id":16,"name":"right-arm","bounds":{"top":401,"left":274,"bottom":1017,"right":422},"visible":true,"sprites":null},{"id":20,"name":"torso-add","bounds":{"top":392,"left":272,"bottom":910,"right":520},"visible":true,"sprites":null},{"id":22,"name":"right-leg","bounds":{"top":880,"left":153,"bottom":1729,"right":525},"visible":true,"sprites":null},{"id":24,"name":"left-leg-add","bounds":{"top":873,"left":339,"bottom":1703,"right":598},"visible":true,"sprites":null}]
}
铁汁们,看懂了点啥?原来对于每个图层来说,ps都已经把bounds算好了,这个bounds就是对这个图层中的图像求的包围盒诶!所以PhotoShop用的包围盒算法又是什么捏,这里就不多说了。但是注意包围盒不是轮廓线。
在危险的unity插件边缘试探
作为一款吊炸天的ps2d插件,我真的不知道为啥用户这么少,但是不妨碍我们研究研究这款插件的源码。感谢这个插件的程序员,只有这些cs文件~
当我们把上面生成xx.psd2dmap和xx.psd文件放在unity的文件目录下时,打开插件,进行一些列操作会发生什么呢。
我上个
大致的类图:
总结起来很简单:就是根据ps插件生成的xx.psd2dmap这个json文件,找到ps导出的对应的分层png图片,转化为Unity自带的SerializedSprite类,然后根据json文件中的layerorder、pixelbound等参数计算出这些分层sprite的中心点位置、根据pixelToUnit等转化到场景中,得到这些sprite在Scene场景中的position。这样在Unity中展示的就是一个有着和photoshop中一样拓扑结构的角色(物体)。
在另一款危险的unity插件边缘试探
试探过一次危险边缘之后,虽然对代码并不能说完全搞透彻,但是也大体知道都做了什么了。再次感慨为什么天才程序员这么多。。这么牛。。。让我这种垃圾基因怎么活。。
回到开头提出来的最后一个问题:
以及如何对其进行轮廓检测呢?(unity是如何做到的呢?
看起来PS2D插件只是读取并分析psd文件然后无缝融合到Unity,生成分层的拓扑物体到场景中。但是对于这些sprite而言,他们的轮廓提取和切分具体又用了什么样的原理和算法呢?(Unity自带的slice搞不动的)
让我们梦回Anima2D这个插件(诶,我之前没有写过这个插件的哈!)那,让我们初探Anima2D这个插件~(unity store免费)。这一切要从一个叫做SpriteMesh的自定义类说起
重点类图:
可以看出来,当程序把这个png转化为object
然后转化为Sprite
的时候,Unity内部就已经自动算好了轮廓点(网格点),包围盒
等一系列参数…后续的三角剖分及展示在界面上这些东西,程序员可以自己定义,程序员还可以通过人机交互,自定义网格顶点等信息。
鉴于Unity不开源它的源代码,个人猜测它是用了类似OpenCV中的findCountours()
这个函数的内部算法,先把图像二值化,然后边缘检测,再对边缘线求离散点,作为网格顶点,继续调整~
既然我们通过Anima2D插件接到了unity自带的类Sprite类给出的网格顶点,那么后续的三角剖分,以及调整网格,就完全可以学习Anima2D的源代码来剖析了。
当我们在Unity的Asset/xxx目录下看到一个png图片,我们右键,create->Anima2d->spritemesh
,程序会走入这样一个流程:
public static void CreateSpriteMesh(Texture2D texture){if(texture){Object[] objects = AssetDatabase.LoadAllAssetsAtPath(AssetDatabase.GetAssetPath(texture));for (int i = 0; i < objects.Length; i++){Object o = objects [i];Sprite sprite = o as Sprite;if (sprite) {EditorUtility.DisplayProgressBar ("Processing " + texture.name, sprite.name, (i+1) / (float)objects.Length);CreateSpriteMesh(sprite);}}EditorUtility.ClearProgressBar();}}
这里有一点想说一下,看第一条语句,通过断点监视objects变量,我们可以看到一个png的texture(在unity面板上已经改成了sprite),经过这么一个getAsset就会变成两个object,一个负责是texture,一个负责是sprite,说明Unity在内部(或者内存中)已经存在这个对应的sprite文件(信息),只是没显示出来而已。
基于好奇,我们去路径下看了看这个png对应的meta文件,果然是有相关信息的,至于LoadAllAssetAtPath()
这个函数到底怎么读的文件,我们就暂不深究了,谁让unity爸爸没有公开源码,我又菜到抠脚呢:
ok,我们继续往下:
我们可以看到Unity已经初步把这个sprite的顶点、uv坐标、网格三角形坐标先给确定了,这一套就相当于UV映射的初步,纹理和网格已经有了简单的connection
接下来就是生成相关的asset资源:
public static SpriteMesh CreateSpriteMesh(Sprite sprite){SpriteMesh spriteMesh = SpriteMeshPostprocessor.GetSpriteMeshFromSprite(sprite);SpriteMeshData spriteMeshData = null;if(!spriteMesh && sprite){string spritePath = AssetDatabase.GetAssetPath(sprite);string directory = Path.GetDirectoryName(spritePath);string assetPath = AssetDatabase.GenerateUniqueAssetPath(directory + Path.DirectorySeparatorChar + sprite.name + ".asset");spriteMesh = ScriptableObject.CreateInstance<SpriteMesh>();InitFromSprite(spriteMesh,sprite);AssetDatabase.CreateAsset(spriteMesh,assetPath);spriteMeshData = ScriptableObject.CreateInstance<SpriteMeshData>();spriteMeshData.name = spriteMesh.name + "_Data";spriteMeshData.hideFlags = HideFlags.HideInHierarchy;InitFromSprite(spriteMeshData,sprite);AssetDatabase.AddObjectToAsset(spriteMeshData,assetPath);UpdateAssets(spriteMesh,spriteMeshData);AssetDatabase.SaveAssets();AssetDatabase.ImportAsset(assetPath);Selection.activeObject = spriteMesh;}return spriteMesh;}
虽然我们在之前的博客讲过ScriptableObject和SerializedObject的区别,但是这里我还是要深入一下。看附录。
然后我们就生成了SpriteMesh和SpriteMeshData这个asset资源
,并合并存放在了本地文件夹下,这个东西到时候在游戏运行时就会被疯狂引用。
程序运行到这里,我们的断点单步执行,又走到了如下这里(涉及观察者模式,请看我的github设计模式里有讲解:)
SpriteMeshEditorWindow
类用来控制相关SpriteMesh和SpriteMeshData的asset资源
在unity Window面板上的显示(类似Scene场景的离线渲染)。
然后有一个sliceEditor
控制德劳内三角剖分和细化(用的是微软维护的Triangle C# 开源库),SpriteMeshInstance
负责作为一个组件挂载到物体上,控制到Scene中的渲染。
如下是SpriteMeshUtils.cs中的部分代码,控制三角剖分最底层算法的调用,我曾经用Fade库写过三角剖分,基本用法没啥大差别
public static void Triangulate(List<Vector2> vertices, List<IndexedEdge> edges, List<Hole> holes,ref List<int> indices){indices.Clear();if(vertices.Count >= 3){InputGeometry inputGeometry = new InputGeometry(vertices.Count);for(int i = 0; i < vertices.Count; ++i){Vector2 position = vertices[i];inputGeometry.AddPoint(position.x,position.y);}for(int i = 0; i < edges.Count; ++i){IndexedEdge edge = edges[i];inputGeometry.AddSegment(edge.index1,edge.index2);}for(int i = 0; i < holes.Count; ++i){Vector2 hole = holes[i].vertex;inputGeometry.AddHole(hole.x,hole.y);}TriangleNet.Mesh triangleMesh = new TriangleNet.Mesh();triangleMesh.Triangulate(inputGeometry);foreach (TriangleNet.Data.Triangle triangle in triangleMesh.Triangles){if(triangle.P0 >= 0 && triangle.P0 < vertices.Count &&triangle.P0 >= 0 && triangle.P1 < vertices.Count &&triangle.P0 >= 0 && triangle.P2 < vertices.Count){indices.Add(triangle.P0);indices.Add(triangle.P2);indices.Add(triangle.P1);}}}}public static void Tessellate(List<Vector2> vertices, List<IndexedEdge> indexedEdges, List<Hole> holes, List<int> indices, float tessellationAmount){if(tessellationAmount <= 0f){return;}indices.Clear();if(vertices.Count >= 3){InputGeometry inputGeometry = new InputGeometry(vertices.Count);for(int i = 0; i < vertices.Count; ++i){Vector2 vertex = vertices[i];inputGeometry.AddPoint(vertex.x,vertex.y);}for(int i = 0; i < indexedEdges.Count; ++i){IndexedEdge edge = indexedEdges[i];inputGeometry.AddSegment(edge.index1,edge.index2);}for(int i = 0; i < holes.Count; ++i){Vector2 hole = holes[i].vertex;inputGeometry.AddHole(hole.x,hole.y);}TriangleNet.Mesh triangleMesh = new TriangleNet.Mesh();TriangleNet.Tools.Statistic statistic = new TriangleNet.Tools.Statistic();triangleMesh.Triangulate(inputGeometry);triangleMesh.Behavior.MinAngle = 20.0;triangleMesh.Behavior.SteinerPoints = -1;triangleMesh.Refine(true);statistic.Update(triangleMesh,1);triangleMesh.Refine(statistic.LargestArea / tessellationAmount);triangleMesh.Renumber();vertices.Clear();indexedEdges.Clear();foreach(TriangleNet.Data.Vertex vertex in triangleMesh.Vertices){vertices.Add(new Vector2((float)vertex.X,(float)vertex.Y));}foreach(TriangleNet.Data.Segment segment in triangleMesh.Segments){indexedEdges.Add(new IndexedEdge(segment.P0,segment.P1));}foreach (TriangleNet.Data.Triangle triangle in triangleMesh.Triangles){if(triangle.P0 >= 0 && triangle.P0 < vertices.Count &&triangle.P0 >= 0 && triangle.P1 < vertices.Count &&triangle.P0 >= 0 && triangle.P2 < vertices.Count){indices.Add(triangle.P0);indices.Add(triangle.P2);indices.Add(triangle.P1);}}}}
基本网格剖分和场景渲染这里已经搞的差不多了,看如下的类图,我来大刀阔斧总结一下(记得关注序号代表流程顺序,比较方便理清):
但是,进行到这里远远没有结束。为什么,因为我们的目的是动画,接下来就是骨骼的权重绑定方面的理解,ok往下走啦!
骨骼这里使用的流程很简单,骨骼是一种Object,所以在Hierarchy面板上右键create->2d object->bone
,然后把骨骼拖到SpriteMeshInstance
组件上,(注意让骨骼的位置和要绑定的纹理层不要间隔太远)再从SpriteMeshEditor
编辑网格绑定骨骼就可以啦。有个细节需要注意,当处理好骨骼和图层的绑定之后,点击Apply,这时候在场景中的某个图层,就会从SpriteMeshInstance组件+MeshFilter组件+MeshRender组件变成SpriteMeshInstance组件+Skinned Mesh Renderer组件。
这里用到的重点类图如下所示:
public void BindBone(Bone2D bone){if(spriteMeshInstance && bone){BindInfo bindInfo = new BindInfo();bindInfo.bindPose = bone.transform.worldToLocalMatrix * spriteMeshInstance.transform.localToWorldMatrix;bindInfo.boneLength = bone.localLength;bindInfo.path = BoneUtils.GetBonePath (bone);bindInfo.name = bone.name;bindInfo.color = ColorRing.GetColor(bindPoses.Count);if(!bindPoses.Contains(bindInfo)){bindPoses.Add (bindInfo);isDirty = true;}}}
public void CalculateAutomaticWeights(List<Node> targetNodes){float pixelsPerUnit = SpriteMeshUtils.GetSpritePixelsPerUnit(spriteMesh.sprite);if(nodes.Count <= 0){Debug.Log("Cannot calculate automatic weights from a SpriteMesh with no vertices.");return;}if(bindPoses.Count <= 0){Debug.Log("Cannot calculate automatic weights. Specify bones to the SpriteMeshInstance.");return;}if(!spriteMesh)return;List<Vector2> controlPoints = new List<Vector2>();List<IndexedEdge> controlPointEdges = new List<IndexedEdge>();List<int> pins = new List<int>();foreach(BindInfo bindInfo in bindPoses){Vector2 tip = SpriteMeshUtils.VertexToTexCoord(spriteMesh,pivotPoint,bindInfo.position,pixelsPerUnit);Vector2 tail = SpriteMeshUtils.VertexToTexCoord(spriteMesh,pivotPoint,bindInfo.endPoint,pixelsPerUnit);if(bindInfo.boneLength <= 0f){int index = controlPoints.Count;controlPoints.Add(tip);pins.Add(index);continue;}int index1 = -1;if(!ContainsVector(tip,controlPoints,0.01f, out index1)){index1 = controlPoints.Count;controlPoints.Add(tip);}int index2 = -1;if(!ContainsVector(tail,controlPoints,0.01f, out index2)){index2 = controlPoints.Count;controlPoints.Add(tail);}IndexedEdge edge = new IndexedEdge(index1, index2);controlPointEdges.Add(edge);}UnityEngine.BoneWeight[] boneWeights = BbwPlugin.CalculateBbw(m_TexVertices.ToArray(), indexedEdges.ToArray(), controlPoints.ToArray(), controlPointEdges.ToArray(), pins.ToArray());foreach(Node node in targetNodes){UnityEngine.BoneWeight unityBoneWeight = boneWeights[node.index];SetBoneWeight(node,CreateBoneWeightFromUnityBoneWeight(unityBoneWeight));}isDirty = true;}
这里我竟然发现,难道继承自MonoBehavior的类都是可以序列化的?因为我发现这样一行代码:
public static void UpdateRenderer(SpriteMeshInstance spriteMeshInstance, bool undo = true){if(!spriteMeshInstance){return;}SerializedObject spriteMeshInstaceSO = new SerializedObject(spriteMeshInstance);SpriteMesh spriteMesh = spriteMeshInstaceSO.FindProperty("m_SpriteMesh").objectReferenceValue as SpriteMesh;if(spriteMesh){Mesh sharedMesh = spriteMesh.sharedMesh;if(sharedMesh.bindposes.Length > 0 && spriteMeshInstance.bones.Count > sharedMesh.bindposes.Length){spriteMeshInstance.bones = spriteMeshInstance.bones.GetRange(0,sharedMesh.bindposes.Length);}if(CanEnableSkinning(spriteMeshInstance)){MeshFilter meshFilter = spriteMeshInstance.cachedMeshFilter;MeshRenderer meshRenderer = spriteMeshInstance.cachedRenderer as MeshRenderer;if(meshFilter){if(undo){Undo.DestroyObjectImmediate(meshFilter);}else{GameObject.DestroyImmediate(meshFilter);}}if(meshRenderer){if(undo){Undo.DestroyObjectImmediate(meshRenderer);}else{GameObject.DestroyImmediate(meshRenderer);}}SkinnedMeshRenderer skinnedMeshRenderer = spriteMeshInstance.cachedSkinnedRenderer;if(!skinnedMeshRenderer){if(undo){skinnedMeshRenderer = Undo.AddComponent<SkinnedMeshRenderer>(spriteMeshInstance.gameObject);}else{skinnedMeshRenderer = spriteMeshInstance.gameObject.AddComponent<SkinnedMeshRenderer>();}}skinnedMeshRenderer.bones = spriteMeshInstance.bones.ConvertAll( bone => bone.transform ).ToArray();if(spriteMeshInstance.bones.Count > 0){skinnedMeshRenderer.rootBone = spriteMeshInstance.bones[0].transform;}EditorUtility.SetDirty(skinnedMeshRenderer);}else{SkinnedMeshRenderer skinnedMeshRenderer = spriteMeshInstance.cachedSkinnedRenderer;MeshFilter meshFilter = spriteMeshInstance.cachedMeshFilter;MeshRenderer meshRenderer = spriteMeshInstance.cachedRenderer as MeshRenderer;if(skinnedMeshRenderer){if(undo){Undo.DestroyObjectImmediate(skinnedMeshRenderer);}else{GameObject.DestroyImmediate(skinnedMeshRenderer);}}if(!meshFilter){if(undo){meshFilter = Undo.AddComponent<MeshFilter>(spriteMeshInstance.gameObject);}else{meshFilter = spriteMeshInstance.gameObject.AddComponent<MeshFilter>();}EditorUtility.SetDirty(meshFilter);}if(!meshRenderer){if(undo){meshRenderer = Undo.AddComponent<MeshRenderer>(spriteMeshInstance.gameObject);}else{meshRenderer = spriteMeshInstance.gameObject.AddComponent<MeshRenderer>();}EditorUtility.SetDirty(meshRenderer);}}}}
可以看到在绑定完骨骼以后,对应的SpriteMesh和SpriteMeshData资源是有跟新的,至于跟新了什么,我们结合代码和文件来看看:
vscode对比文件:
至于骨骼怎么根据这些矩阵,一步步的带动网格变形这个,我们这个博客就不讲了。下个博客再讲。
怎么得到一个简单的Animaton呢,这里用到了Unity自带的Animation
。可以选中角色,在Animation的面板上,new animation cilp
,然后这个角色就会自动添加Animator
组件,对应的是xx.controller.
让Animator记录的应该是Bone2D这个对应的Object所用的position和rotation属性。只记录那个SpriteMeshInstance对应的Object没有用…虽然我也没太搞清楚是为什么。。。
截至目前,基本已经把有用的代码研究的差不多了。但其实还有很多黑盒子看不到,感觉很难受。
比如:
1、Animation Clip的录制程序流程,当我拖动Scene中的骨骼时,走的哪里的代码,让它绑定的纹理网格也跟着动了?哪个模块负责的渲染,SpriteMeshInstance吗?
2、blenshape到底干啥的啊??
3、IK,CCD底层算法还来不及研究…
但是这篇blog我就暂时先写到这里了,后续有啥新的收获再更新~~
课堂小知识
ScriptableObject & SerializedObject
ScriptableObject :https://blog.csdn.net/qq_36383623/article/details/99649941
看代码理解一下,SpriteMesh
是ScriptableObject
类型,我们声明了一个SerializedObject
对象来通过FindProperty()
等函数获取和这个ScriptableObject
对象的一些属性~然后们还看到了objectReferenceValue
这个字段。很明显就是和引用有关啦。