Color Inspector Example

GPU颜色查看器

制作这个颜色查看器的目的 一直以来我都觉得调试GPU代码是一件很困难的事情,在屏幕上显示的颜色会被限制在0和1之间,所以我很希望能够有那么一个工具,能够让我切切实实的看到输出的数据。如果是写的CPU代码的话,又会对伽马空间的矫正之类的有一些担心,而且可复用程度不够高。因此我就希望能够在GPU传回的图像上直接显示颜色和其对应的数值。 碰巧之前在Shader Toy上看到了一些在GPU上输出文字的案例,研究了一下之后,就制作了这么一个颜色查看器。 GPU显示文字的原理 一种是将文字储存到一张贴图上,通过采样这张贴图来显示这些文字,可以通过SDF的方法,自由的调节文字的粗细,也不会有锯齿;另一种是将文字直接用01数据来储存,对应到屏幕上,0的区域显示黑色,1的区域显示白色,本文就采用了这种方式;当然Shader Toy上还有一种是将文字用曲线来储存的,应该是涉及到傅里叶变换。 本文的标准文字是8*12个像素大,每三行进行合并的话,就是一个24 * 4的01矩阵,这就能将一个文字使用uint4来储存,每个通道还多余8bit。具体可以看Shader Toy案例里的描述,我觉得写的十分直观了。 但是这个案例里面有一点我觉得不太好的地方,他对于每一个像素,都要计算每一个文字的颜色,也就是说文字越多计算的复杂度越大。我想的是最好能够使用StructuredBuffer的方式,先用一个pass计算要显示的文字,然后对于每一个像素,计算出要显示的文字的编号,再在Structured Buffer中找到对应的文字,最终显示出来。这样每个像素应该之多只需要做一次文字到颜色的计算,能够提高效率。 颜色查看器具体的操作 首先要先把需要显示的文字对应的uint4值计算出来,这一步之前的案例里已经有现成的数据了。最好是将这些数据组成一个数列,这样我们之后能够用其编号来访问文字对应的数据。 执行一个(1, 1, 1)的Compute Shader,获取要采样的像素点的颜色。对像素点的每一位,都将其转换成要输出的文字对应的编号,储存到_Append_CharacterIndexBuffer中。像本文中需要显示7这个数字的话,需要添加23这个编号到我们的输出数据中。同时,由于每一个像素点有rgba四个通道,但我们输出的是一个RWStructuredBuffer<int>的数据,我们还需要一个列表_RW_LengthBuffer(相当于指针),记录每一个通道对应的数据数列的区间。 对整个画面执行Compute Shader,根据每一个像素点的位置,判断是显示放大的图像、每个通道对应的文字或是原始的画面。如果要显示文字的话,根据需要显示的RGBA通道,找到对应的_RW_LengthBuffer获得编号区间,再找到_Struct_CharacterIndexBuffer中获得对应的编号,通过编号找到对应的文字。最后再根据像素的位置,求出在该文字中应当显示的颜色。 在Unity中我选择了用Volume和Renderer Feature的方法,对后处理之前的画面进行颜色查看的操作。 整体想法还是尽量的减少采样,减少文字到颜色的计算,以及减少分支判断。 Font.hlsl 就是案例中的8*12的文字对应的uint4表,这里将其用数列来保存。 #define character_spc characterSet[0] #define character_exc characterSet[1] #define character_quo characterSet[2] #define character_hsh characterSet[3] #define character_dol characterSet[4] #define character_pct characterSet[5] #define character_amp characterSet[6] #define character_apo characterSet[7] #define character_lbr characterSet[8] #define character_rbr characterSet[9] #define character_ast characterSet[10] #define character_crs characterSet[11] #define character_com characterSet[12] #define character_dsh characterSet[13] #define character_per characterSet[14] #define character_lsl characterSet[15] #define character_0 characterSet[16] #define character_1 characterSet[17] #define character_2 characterSet[18] #define character_3 characterSet[19] #define character_4 characterSet[20] #define character_5 characterSet[21] #define character_6 characterSet[22] #define character_7 characterSet[23] #define character_8 characterSet[24] #define character_9 characterSet[25] #define character_col characterSet[26] #define character_scl characterSet[27] #define character_les characterSet[28] #define character_equ characterSet[29] #define character_grt characterSet[30] #define character_que characterSet[31] #define character_ats characterSet[32] #define character_A characterSet[33] #define character_B characterSet[34] #define character_C characterSet[35] #define character_D characterSet[36] #define character_E characterSet[37] #define character_F characterSet[38] #define character_G characterSet[39] #define character_H characterSet[40] #define character_I characterSet[41] #define character_J characterSet[42] #define character_K characterSet[43] #define character_L characterSet[44] #define character_M characterSet[45] #define character_N characterSet[46] #define character_O characterSet[47] #define character_P characterSet[48] #define character_Q characterSet[49] #define character_R characterSet[50] #define character_S characterSet[51] #define character_T characterSet[52] #define character_U characterSet[53] #define character_V characterSet[54] #define character_W characterSet[55] #define character_X characterSet[56] #define character_Y characterSet[57] #define character_Z characterSet[58] #define character_lsb characterSet[59] #define character_rsl characterSet[60] #define character_rsb characterSet[61] #define character_pow characterSet[62] #define character_usc characterSet[63] #define character_a characterSet[64] #define character_b characterSet[65] #define character_c characterSet[66] #define character_d characterSet[67] #define character_e characterSet[68] #define character_f characterSet[69] #define character_g characterSet[70] #define character_h characterSet[71] #define character_i characterSet[72] #define character_j characterSet[73] #define character_k characterSet[74] #define character_l characterSet[75] #define character_m characterSet[76] #define character_n characterSet[77] #define character_o characterSet[78] #define character_p characterSet[79] #define character_q characterSet[80] #define character_r characterSet[81] #define character_s characterSet[82] #define character_t characterSet[83] #define character_u characterSet[84] #define character_v characterSet[85] #define character_w characterSet[86] #define character_x characterSet[87] #define character_y characterSet[88] #define character_z characterSet[89] #define character_lpa characterSet[90] #define character_bar characterSet[91] #define character_rpa characterSet[92] #define character_tid characterSet[93] #define character_lar characterSet[94] static int4 characterSet[] = { int4(0x000000,0x000000,0x000000,0x000000), int4(0x003078,0x787830,0x300030,0x300000), int4(0x006666,0x662400,0x000000,0x000000), int4(0x006C6C,0xFE6C6C,0x6CFE6C,0x6C0000), int4(0x30307C,0xC0C078,0x0C0CF8,0x303000), int4(0x000000,0xC4CC18,0x3060CC,0x8C0000), int4(0x0070D8,0xD870FA,0xDECCDC,0x760000), int4(0x003030,0x306000,0x000000,0x000000), int4(0x000C18,0x306060,0x603018,0x0C0000), int4(0x006030,0x180C0C,0x0C1830,0x600000), int4(0x000000,0x663CFF,0x3C6600,0x000000), int4(0x000000,0x18187E,0x181800,0x000000), int4(0x000000,0x000000,0x000038,0x386000), int4(0x000000,0x0000FE,0x000000,0x000000), int4(0x000000,0x000000,0x000038,0x380000), int4(0x000002,0x060C18,0x3060C0,0x800000), int4(0x007CC6,0xD6D6D6,0xD6D6C6,0x7C0000), int4(0x001030,0xF03030,0x303030,0xFC0000), int4(0x0078CC,0xCC0C18,0x3060CC,0xFC0000), int4(0x0078CC,0x0C0C38,0x0C0CCC,0x780000), int4(0x000C1C,0x3C6CCC,0xFE0C0C,0x1E0000), int4(0x00FCC0,0xC0C0F8,0x0C0CCC,0x780000), int4(0x003860,0xC0C0F8,0xCCCCCC,0x780000), int4(0x00FEC6,0xC6060C,0x183030,0x300000), int4(0x0078CC,0xCCEC78,0xDCCCCC,0x780000), int4(0x0078CC,0xCCCC7C,0x181830,0x700000), int4(0x000000,0x383800,0x003838,0x000000), int4(0x000000,0x383800,0x003838,0x183000), int4(0x000C18,0x3060C0,0x603018,0x0C0000), int4(0x000000,0x007E00,0x7E0000,0x000000), int4(0x006030,0x180C06,0x0C1830,0x600000), int4(0x0078CC,0x0C1830,0x300030,0x300000), int4(0x007CC6,0xC6DEDE,0xDEC0C0,0x7C0000), int4(0x003078,0xCCCCCC,0xFCCCCC,0xCC0000), int4(0x00FC66,0x66667C,0x666666,0xFC0000), int4(0x003C66,0xC6C0C0,0xC0C666,0x3C0000), int4(0x00F86C,0x666666,0x66666C,0xF80000), int4(0x00FE62,0x60647C,0x646062,0xFE0000), int4(0x00FE66,0x62647C,0x646060,0xF00000), int4(0x003C66,0xC6C0C0,0xCEC666,0x3E0000), int4(0x00CCCC,0xCCCCFC,0xCCCCCC,0xCC0000), int4(0x007830,0x303030,0x303030,0x780000), int4(0x001E0C,0x0C0C0C,0xCCCCCC,0x780000), int4(0x00E666,0x6C6C78,0x6C6C66,0xE60000), int4(0x00F060,0x606060,0x626666,0xFE0000), int4(0x00C6EE,0xFEFED6,0xC6C6C6,0xC60000), int4(0x00C6C6,0xE6F6FE,0xDECEC6,0xC60000), int4(0x00386C,0xC6C6C6,0xC6C66C,0x380000), int4(0x00FC66,0x66667C,0x606060,0xF00000), int4(0x00386C,0xC6C6C6,0xCEDE7C,0x0C1E00), int4(0x00FC66,0x66667C,0x6C6666,0xE60000), int4(0x0078CC,0xCCC070,0x18CCCC,0x780000), int4(0x00FCB4,0x303030,0x303030,0x780000), int4(0x00CCCC,0xCCCCCC,0xCCCCCC,0x780000), int4(0x00CCCC,0xCCCCCC,0xCCCC78,0x300000), int4(0x00C6C6,0xC6C6D6,0xD66C6C,0x6C0000), int4(0x00CCCC,0xCC7830,0x78CCCC,0xCC0000), int4(0x00CCCC,0xCCCC78,0x303030,0x780000), int4(0x00FECE,0x981830,0x6062C6,0xFE0000), int4(0x003C30,0x303030,0x303030,0x3C0000), int4(0x000080,0xC06030,0x180C06,0x020000), int4(0x003C0C,0x0C0C0C,0x0C0C0C,0x3C0000), int4(0x10386C,0xC60000,0x000000,0x000000), int4(0x000000,0x000000,0x000000,0x00FF00), int4(0x000000,0x00780C,0x7CCCCC,0x760000), int4(0x00E060,0x607C66,0x666666,0xDC0000), int4(0x000000,0x0078CC,0xC0C0CC,0x780000), int4(0x001C0C,0x0C7CCC,0xCCCCCC,0x760000), int4(0x000000,0x0078CC,0xFCC0CC,0x780000), int4(0x00386C,0x6060F8,0x606060,0xF00000), int4(0x000000,0x0076CC,0xCCCC7C,0x0CCC78), int4(0x00E060,0x606C76,0x666666,0xE60000), int4(0x001818,0x007818,0x181818,0x7E0000), int4(0x000C0C,0x003C0C,0x0C0C0C,0xCCCC78), int4(0x00E060,0x60666C,0x786C66,0xE60000), int4(0x007818,0x181818,0x181818,0x7E0000), int4(0x000000,0x00FCD6,0xD6D6D6,0xC60000), int4(0x000000,0x00F8CC,0xCCCCCC,0xCC0000), int4(0x000000,0x0078CC,0xCCCCCC,0x780000), int4(0x000000,0x00DC66,0x666666,0x7C60F0), int4(0x000000,0x0076CC,0xCCCCCC,0x7C0C1E), int4(0x000000,0x00EC6E,0x766060,0xF00000), int4(0x000000,0x0078CC,0x6018CC,0x780000), int4(0x000020,0x60FC60,0x60606C,0x380000), int4(0x000000,0x00CCCC,0xCCCCCC,0x760000), int4(0x000000,0x00CCCC,0xCCCC78,0x300000), int4(0x000000,0x00C6C6,0xD6D66C,0x6C0000), int4(0x000000,0x00C66C,0x38386C,0xC60000), int4(0x000000,0x006666,0x66663C,0x0C18F0), int4(0x000000,0x00FC8C,0x1860C4,0xFC0000), int4(0x001C30,0x3060C0,0x603030,0x1C0000), int4(0x001818,0x181800,0x181818,0x180000), int4(0x00E030,0x30180C,0x183030,0xE00000), int4(0x0073DA,0xCE0000,0x000000,0x000000), int4(0x000000,0x10386C,0xC6C6FE,0x000000), }; ColorInspector....

October 20, 2021 · zznewclear13
Gaussian Blur Cover

使用Group Shared Memory加速高斯模糊

2023年4月5日更新 再议高斯模糊,更实用的高斯模糊。 为什么要用Compute Shader来做高斯模糊 在之前的博客中我说Compute Shader的优势就是快,因为GPU的并行运算比CPU强大很多。但是相比于同样运行在GPU上的Fragment Shader之类,Compute Shader是不是就毫无优势了呢?答案是否定的,在DX11的文档中我们可以看到Thread Group Shared Memory这么一个概念,Compute Shader的Thread Group中的每一个Thread,都可以极快速的访问到对应的Group Shared Memory中的数据,这个效率比采样一张贴图来的高。因此在进行高斯模糊这样的需要大量贴图采样的计算时,先将贴图数据缓存到Group Shared Memory中,再多次访问Group Shared Memory,这样运行的效率会高得多。 相关的一些参考可以在英伟达的PPT里找到。 Compute Shader进行高斯模糊的具体操作 这里暂时不使用半分辨率的优化方法,目的是把SrcIden对应的RenderTexture经过高斯模糊储存到DestIden中,这时我们需要一张临时的相同大小的RenderTextureBlurIden。 确定需要的最大的高斯模糊的像素宽度MAX_RADIUS,越大的高斯模糊宽度,需要越大的Group Shared Memory,而Group Shared Memory的大小是有上限的(cs_5.0是32768 bytes)。当然也没有必要在全分辨率的情况下做特别大的高斯模糊就是了。这里我设置最大的高斯模糊像素数是32,即高斯模糊当前像素点和最远采样点之间的距离不能超过32(双线性采样的话还要缩小一个像素)。 高斯模糊往往使用水平和竖直两个高斯核心进行模糊,对应的需要两个Compute Shader Kernel(也可以写成一个,不过思考起来有点绕),我们这里设置两个Kernel,对应水平和竖直两个pass。水平Kernel使用[numthreads(64, 1, 1)],最高可以是1024,竖直Kernel则使用[numthreads(1, 64, 1)]。 由于高斯模糊中会采样像素点的左右(上下)两侧的像素,Group Shared Memory需要在GroupThreads的基础上向两侧扩大最大高斯模糊像素数MAX_RADIUS,用于保存额外的像素数据。这时我们需要的Group Shared Memory的大小是numthreads + 2 * MAX_RADIUS个,在本文章中是64 + 2 * 32个float3的数据。 GaussianBlur.cs 这里略去Unity SRP的设置pass的操作,仅展示高斯模糊相关的操作。当blurRadius过大时,就能看到明显的多重采样的痕迹了。 private void DoGaussianBlurHorizontal(CommandBuffer cmd, RenderTargetIdentifier srcid, RenderTargetIdentifier dstid, ComputeShader computeShader, float blurRadius) { int gaussianBlurKernel = computeShader.FindKernel("GaussianBlurHorizontalMain"); computeShader.GetKernelThreadGroupSizes(gaussianBlurKernel, out uint x, out uint y, out uint z); cmd....

August 19, 2021 · zznewclear13
Signed Distance Field Cover

使用Compute Shader计算有向距离场

什么是有向距离场以及它能用来干什么 有向距离场记录的是从一个点到集合边界的的距离值,其值的正负对应该点在集合外部或内部。有向距离场有很广的应用范围可以用来简单的生成Voronoi图形,可以用来做全局光照的计算,可以用来做两个形状的平滑的变形,可以用来做高清晰度的字体,也可以用来做Ray March(虽然我认为不如直接光线追踪求交来的效率高)。像原神就使用了SDF的方法,生成了角色脸部的阴影图,从而让角色脸部的阴影能自然的变化。 那么什么又是Jump Flooding Algorithm呢 Jump Flooding Algorithm是荣国栋在他的博士论文Jump Flooding Algorithm On Graphics Hardware And Its Applications提出的一种在GPU上运行的能够快速传播某个像素的信息到其他像素的算法。 普通的Flooding算法在一次运行中,固定向相邻的一个像素的像素传播信息,而Jump Flooding则是按照2的幂次递增或是递减来传播信息。这和之前提到的并行计算——Reduction的想法差不多。下图演示了普通的Flooding和Jump Flooding的过程: 使用JFA计算一张2D图片对应的SDF贴图 首先在Unity中创建JumpFlooding.cs, JFAComputeShader.compute, 和JFAVisualize.shader,分别用来执行Compute Shader,使用JFA算法计算SDF和可视化JFA算法的结果。 这里使用一张RGB通道为灰色,Alpha通道写着“JFA”的贴图作为我们2D图片的输入。 整体思路和需要注意的事项 先从简单实现功能上来考虑,暂时忽略掉抗锯齿的需求,直接对Alpha通道的值按照0.5来划分出图形的内部和外部,大于等于0.5为外部,小于0.5为内部。 SDF需要要计算距离,这里使用像素点中心到另一个像素点中心的距离(也可以使用像素点左下角到另一个像素点左下角的距离,不过为了明确起见,还是加上这半个像素的偏移比较好)。距离可以用uv的大小来表示,也可以用像素数量来表示,针对图像长宽不同的情况,这里以一个像素宽度为1来表示两个像素点的距离。在最后采样JFA的Render Texture的时候,也要注意使用sampler_PointClamp来进行采样,计算距离时也不能仅仅使用uv来计算,而是要使用像素中间的点的位置来进行计算。 普通的JFA算法会使用到Render Texture的两个通道,来标记像素对应的最近边界像素的UV,由于记录的是UV的数值而不是颜色信息,Render Texture要储存在线性空间中。由于要同时计算内部和外部的点到边界的有向距离,JFA算法会使用到Render Texture全部的四个通道,这里使用前两位记录位于内部的像素对应的边界像素的坐标,用后两位记录位于外部的像素对应的边界像素的坐标,即对于内部的像素(nearestUV.x, nearestUV.y, Z, W),对于外部的像素(X, Y, nearestUV.x, nearestUV.y),XYZW则可以用来表示该点为最初始的内外部的点、包含JFA传递的信息的点。不包含JFA传递的信息的点。 在JFA计算之前,需要先让贴图对应的点包含JFA信息,也就是说,对于内部的点初始化为(UV.x, UV.y, -1, -1),对于外部的点初始化为(-1, -1, UV.x, UV.y)。这要求我们使用的Render Texture格式为至少R16G16B16A16 SFloat。 在JFA的计算中,以外部的像素点为例,所进行的操作是:采样上次经过JFA操作的Render Texture;根据XY通道判断该点在边界的内部还是外部,内部就跳过;对外部的像素点,通过XY通道判断是否已包含JFA的信息,如果包含,根据ZW通道计算出当前点到其包含的最近像素的距离,如果不包含,将这个距离设置为一个极大的常数;分别采样距离像素点2的幂次的距离的八个像素,判断这些像素是否已包含JFA的信息,如果不包含,采样下一个点,如果包含,根据这些像素的ZW通道计算出当前点到这些像素包含的最近像素的距离,并和上一步算出的距离进行比较,如果小于上一步算出的距离,则证明该像素对应的最近像素为周围点包含的像素,更新该像素的ZW通道,并且将XY通道标记成已包含JFA的信息。 采样周边像素的步长从2D贴图的长宽的一半向上取整开始,每次JFA都取上一步步长的一半向上取整作为新的步长,一直进行到步长为(1, 1),进行最后一次JFA计算。 经过JFA计算之后,还需要将分别表示内部的点对应的最近像素的UV和外部的点对应的最近像素的UV结合起来,储存为一张贴图,可以是(nearestUV.x, nearestUV.y, 0, inside?1:0),也可以是(distance * inside?-1:1, 1)。 应该是跟当前平台有关,有时候会出现贴图上下颠倒的情况,可以用UNITY_UV_STARTS_AT_TOP来协助解决,不过compute shader可能需要自己启用这个宏,这里就直接硬写在shader里,不做平台判断了。 JFAComputeShader.compute 根据上面的整体思路,我们需要三个kernel,一个用来初始化,一个做JFA计算,最后一个用来合成最后的贴图。Compute Shader的关键字需要Unity 2020以上(我也不知道具体哪个版本)才能有,这里就暂时用UNITY_2020_2_OR_NEWER这个宏来屏蔽了。 #pragma kernel CopyUVMain #define PIXEL_OFFSET 0....

June 21, 2021 · zznewclear13
Spherical Harmonics Cover

使用Compute Shader计算球谐全局光照

为什么要用球谐函数来计算全局光照 在物体的渲染中,除了计算直接光照的BRDF之外,也要计算间接光照对物体的影响。在引擎中获取间接光照信息的方法通常是在场景中布置一个反射探针,离线渲染一个360度的场景贴图。这样在计算间接光照的高光部分的时候,可以使用视线在物体上的反射方向reflect(-viewDirection, normal),对渲染好的贴图进行采样,再进行brdf的计算,因此这张贴图也会被称作是specular map;然而在计算间接光照的漫反射部分时,因为目标点会受到来自各个方向上的光线带来的漫反射,不能再简单的使用视线的反射来采样这张帖图。这时有两种解决办法,一种是采样这张贴图的mipmap,在一定程度上模糊的mipmap可以认为是综合了各个方向的光照的信息,另一种则是Ravi Ramamoorthi和Pat Hanrahan2001年在An Efficient Representation for Irradiance Environment Maps中提出的,通过球谐函数重新构建低频光照信息的方式,将其作为简介光漫反射部分的贴图。 如何使用球谐函数重新构建光照信息 在Ravi Ramamoorthi的论文中他给出了球谐参数的计算公式和重构光照信息的公式: $$ \tag*{球谐参数} L_{lm} = \int_{\theta = 0}^\pi\int_{\theta = 0}^{2\pi}L(\theta)Y_{lm}(\theta, \phi)sin\theta d\theta d\phi $$ $$ \begin{align*}其中(x, y, z) &= (sin\theta cos\phi, sin\theta sin\phi, cos\theta) \cr Y_{00}(\theta, \phi) &= 0.282095 \cr (Y_{11};Y_{10};Y_{1-1})(\theta, \phi) &= 0.488603(x;z;y) \cr (Y_{21};Y_{2-1};Y_{2-2})(\theta, \phi) &= 1.092548(xz;yz;xy) \cr Y_{20}(\theta, \phi) &= 0.315392(3z^2 - 1) \cr Y_{22}(\theta, \phi) &= 0.546274(x^2 - y^2) \end{align*} $$ $$ \tag*{重构光照} \begin{equation}\begin{split} E(n) =&\ c_1L_{22}(x^2 - y^2) + c_3L_{20}z^2 + c_4L_{00} - c_5L_{20} \cr +&\ 2c_1(L_{2-2}xy + L_{21}xz + L_{2-1}yz) \cr +&\ 2c_2(L_{11}x + L_{1-1}y + L_{10}z)\end{split}\end{equation} $$ $$ 其中c1 = 0....

June 17, 2021 · zznewclear13
Compute Shader Cover

Unity Compute Shader备忘录

什么是Compute Shader,以及它能用来干什么 首先根据Direct3D 11的说明文档,我们可以知道一个Compute Shader是一个能够利用泛式(通用)的内存访问(即输入和输出),来进行任意运算的可编程着色器。作为类比,我认为Compute Shader和Unity中的Job System很像,准备好一系列的输入,并设置好输出的目标,然后执行这个Compute Shader或者Job,就能得到想要的结果。GPU和多线程,我认为在某种程度上是异曲同工的。Job System有一个优势,就是快,而Compute Shader也有这个优势,而且还要更快!Compute shader背后的思想就是General-purpose computing on graphics processing units(GPGPU),即在图形处理单元上进行通用计算。 如何在Unity里面使用Compute Shader 在Unity中创建Compute Shader之后,会得到一个默认的Compute Shader,里面的代码是这样的: // Each #kernel tells which function to compile; you can have many kernels #pragma kernel CSMain // Create a RenderTexture with enableRandomWrite flag and set it // with cs.SetTexture RWTexture2D<float4> Result; [numthreads(8,8,1)] void CSMain (uint3 id : SV_DispatchThreadID) { // TODO: insert actual code here! Result[id.xy] = float4(id.x & id....

June 16, 2021 · zznewclear13
zznewclear13 技术美术 图形学 个人博客 technical art computer graphics