• Shadertoy 教程 Part 14 使用符号距离场函数


    Note: This series blog was translated from Nathan Vaughn's Shaders Language Tutorial and has been authorized by the author. If reprinted or reposted, please be sure to mark the original link and description in the key position of the article after obtaining the author’s consent as well as the translator's. If the article is helpful to you, click this Donation link to buy the author a cup of coffee.
    说明:该系列博文翻译自Nathan Vaughn着色器语言教程。文章已经获得作者翻译授权,如有转载请务必在取得作者译者同意之后在文章的重点位置标明原文链接以及说明。如果你觉得文章对你有帮助,点击此打赏链接请作者喝一杯咖啡。

    朋友们,你们好!欢迎来到Shadertoy的第十四篇教程。你以前有想过Shadertoy上的那些复杂的形状是如何被绘制出来的呢?我们已经学会了如何绘制球和立方体,但是其他的一些复杂的形状又该如何绘制呢?在本篇文章中,我们将通过Inigo Quilez大神,同时也是Shadertoy的联合创始人提供的SDF操作方法,来学习如何绘制复杂的形状。

    初始化

    下面我们创建了一份光线步进算法模板,该算法我们之前使用过,如果你是需要开发3D场景,它对你会非常有用的。我们下面就开始吧:

      const int MAX_MARCHING_STEPS = 255;
    const float MIN_DIST = 0.0;
    const float MAX_DIST = 100.0;
    const float PRECISION = 0.001;
    const float EPSILON = 0.0005;
    const float PI = 3.14159265359;
    const vec3 COLOR_BACKGROUND = vec3(.741, .675, .82);
    const vec3 COLOR_AMBIENT = vec3(0.42, 0.20, 0.1);
    
    mat2 rotate2d(float theta) {
      float s = sin(theta), c = cos(theta);
      return mat2(c, -s, s, c);
    }
    
    float sdSphere(vec3 p, float r, vec3 offset)
    {
      return length(p - offset) - r;
    }
    
    float scene(vec3 p) {
      return sdSphere(p, 1., vec3(0, 0, 0));
    }
    
    float rayMarch(vec3 ro, vec3 rd) {
      float depth = MIN_DIST;
      float d; // distance ray has travelled
    
      for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
        vec3 p = ro + depth * rd;
        d = scene(p);
        depth += d;
        if (d < PRECISION || depth > MAX_DIST) break;
      }
      
      d = depth;
      
      return d;
    }
    
    vec3 calcNormal(in vec3 p) {
        vec2 e = vec2(1, -1) * EPSILON;
        return normalize(
          e.xyy * scene(p + e.xyy) +
          e.yyx * scene(p + e.yyx) +
          e.yxy * scene(p + e.yxy) +
          e.xxx * scene(p + e.xxx));
    }
    
    mat3 camera(vec3 cameraPos, vec3 lookAtPoint) {
    	vec3 cd = normalize(lookAtPoint - cameraPos);
    	vec3 cr = normalize(cross(vec3(0, 1, 0), cd));
    	vec3 cu = normalize(cross(cd, cr));
    	
    	return mat3(-cr, cu, -cd);
    }
    
    void mainImage( out vec4 fragColor, in vec2 fragCoord )
    {
      vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
      vec2 mouseUV = iMouse.xy/iResolution.xy;
      
      if (mouseUV == vec2(0.0)) mouseUV = vec2(0.5); // trick to center mouse on page load
    
      vec3 col = vec3(0);
      vec3 lp = vec3(0);
      vec3 ro = vec3(0, 0, 3); // ray origin that represents camera position
      
      float cameraRadius = 2.;
      ro.yz = ro.yz * cameraRadius * rotate2d(mix(-PI/2., PI/2., mouseUV.y));
      ro.xz = ro.xz * rotate2d(mix(-PI, PI, mouseUV.x)) + vec2(lp.x, lp.z);
    
      vec3 rd = camera(ro, lp) * normalize(vec3(uv, -1)); // ray direction
    
      float d = rayMarch(ro, rd); // signed distance value to closest object
    
      if (d > MAX_DIST) {
        col = COLOR_BACKGROUND; // ray didn't hit anything
      } else {
        vec3 p = ro + rd * d; // point discovered from ray marching
        vec3 normal = calcNormal(p); // surface normal
    
        vec3 lightPosition = vec3(0, 2, 2);
        vec3 lightDirection = normalize(lightPosition - p) * .65; // The 0.65 is used to decrease the light intensity a bit
    
        float dif = clamp(dot(normal, lightDirection), 0., 1.) * 0.5 + 0.5; // diffuse reflection mapped to values between 0.5 and 1.0
    
        col = vec3(dif) + COLOR_AMBIENT;    
      }
    
      fragColor = vec4(col, 1.0);
    }
    
    

    运行以上的代码,你就能在屏幕上看到一个球。

    我们来分析一下这段代码,弄明白光线步进算法是如何工作的。在代码的顶部位置,我们定义了一些常量,这些常量我们在第六篇教程中看到过了。

    const int MAX_MARCHING_STEPS = 255;
    const float MIN_DIST = 0.0;
    const float MAX_DIST = 100.0;
    const float PRECISION = 0.001;
    const float EPSILON = 0.0005;
    const float PI = 3.14159265359;
    const vec3 COLOR_BACKGROUND = vec3(.741, .675, .82);
    const vec3 COLOR_AMBIENT = vec3(0.42, 0.20, 0.1);
    
    

    我们通过定义变量的方式存储了背景颜色以及环境光照的颜色,这样我们就能快速地更改3D物体的颜色。下一步,我们将会定义一个rotate2d函数,该函数是用来在2D平面上对物体进行旋转的,我们在第10篇教程中讨论过这点。我们将通过这个函数,使用鼠标移动3D模型。

      mat2 rotate2D(float theta) {
        float s = sin(theta), c = cos(theta);
        return mat2(c, -s, s, c);
      }
    

    接下来使用的函数是创建3D场景的基本工具函数。我们在第六篇教程中首次学过它。sdSphere函数是一个用来创建球的符号距离场函数(SDF)。scene函数则是用来渲染场景中所有的物体。如果你读过Shadertoy上的代码,scene函数也被命名为map函数。

      float sdSphere(vec3 p, float r, vec3 offset)
    {
      return length(p - offset) - r;
    }
    
    float scene(vec3 p) {
      return sdSphere(p, 1., vec3(0));
    }
    
    float rayMarch(vec3 ro, vec3 rd) {
      float depth = MIN_DIST;
      float d; // distance ray has travelled
    
      for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
        vec3 p = ro + depth * rd;
        d = scene(p);
        depth += d;
        if (d < PRECISION || depth > MAX_DIST) break;
      }
      
      d = depth;
      
      return d;
    }
    
    vec3 calcNormal(in vec3 p) {
        vec2 e = vec2(1, -1) * EPSILON;
        return normalize(
          e.xyy * scene(p + e.xyy) +
          e.yyx * scene(p + e.yyx) +
          e.yxy * scene(p + e.yxy) +
          e.xxx * scene(p + e.xxx));
    }
    

    接下来,我们又创建了camera函数,通过一个观察目标点来定义相机模型,关于这点,我们也在第10篇教程中提到过。使用目标观察点相机聚焦到一个目标。

      mat3 camera(vec3 cameraPos, vec3 lookAtPoint) {
    	vec3 cd = normalize(lookAtPoint - cameraPos);
    	vec3 cr = normalize(cross(vec3(0, 1, 0), cd));
    	vec3 cu = normalize(cross(cd, cr));
    	
    	return mat3(-cr, cu, -cd);
    }
    

    现在,我们分析一下mainImage函数。重新设置一下UV坐标,这样就能将像素坐标控制在-0.5到0.5之间。我们也需要计算方位比例,这样x轴的值将会是一些处在整数和负数之间的值。

      vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
    

    由于我们使用鼠标旋转3D物体,我们就需要设置mouseUV坐标。当鼠标点击屏幕的时候,我们将其坐标设置在0到1之间。

      vec2 mouseUV = iMouse.xy/iResolution.xy;
    

    这里还有一个问题,当我们在Shadertoy上发布着色器代码,用户首次加载我们的代码时,坐标会以初始值(0,0)作为mouseUV的坐标。我们可以使用一个小技巧,通过给它分配一个新的值来修复这个小缺陷。

      if (mouseUV == vec2(0.0)) mouseUV = vec2(0.5); //  trick to center mouse on page load
    

    接下来,声明了一个颜色变量,col,这个值可以任意设定。然后就需要设置目标观察点,lp,以及射线源头,ro,这些我们在第10篇教程中讨论过。我们的球目前在场中没有偏移,它的位置在vec2(0,0)。我们应该使目标观察点也保持在这个位置上,当然也可以随意调整它。

    vec3 col = vec3(0);
    vec3 lp = vec3(0); // lookat point
    vec3 ro = vec3(0, 0, 3); // ray origin that represents camera position
    

    使用鼠标旋转相机,但是要注意相机与3D物体之间的距离,我们在第10篇教程中学过,使用ratate2d函数让相机在距离物体cameraRadius的间距上旋转。

    float cameraRadius = 2.;
    ro.yz = ro.yz * cameraRadius * rotate2d(mix(-PI/2., PI/2., mouseUV.y));
    ro.xz = ro.xz * rotate2d(mix(-PI, PI, mouseUV.x)) + vec2(lp.x, lp.z);
    
    vec3 rd = camera(ro, lp) * normalize(vec3(uv, -1)); // ray direction
    

    看起来差不多了!除此之外还有许多方式移动相机。不同的人使用的方式有所差别。你只需要选择自己中意的方式即可。

    3D物体的合并操作

    我们已经理解了上面提供的代码的含义了,现在开始做一些3D合并操作吧。我之前已经在第五篇教程中提到过关于2d的一些操作,3D操作其实与它们还是有一些相似的。我们会使用一些工具函数将物体结合在一起或者对它们进行裁剪。这些函数都可以 Inigo Quilez的3D 网页上找到。让我们在scene函数之上定义一些工具函数。

    (结合)Union:把多个图形合并在一起,或者在一块屏幕上同时绘制多个图形。我们应该对这个函数已经很熟悉了,我们之前就是用它在屏幕上绘制多个物体的。

    float opUnion(float d1, float d2) { 
      return min(d1, d2);
    }
    
    float scene(vec3 p) {
      float d1 = sdSphere(p, 1., vec3(0, -1, 0));
      float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
      return opUnion(d1, d2);
    }
    

    Smooth Union: 使两个物体平滑地结合在一起,然后通过参数k,来处理合并边缘平滑程度。k表示等于0表示顺滑度为0,即正常的结合。

      float opSmoothUnion(float d1, float d2, float k) {
      float h = clamp( 0.5 + 0.5*(d2-d1)/k, 0.0, 1.0 );
      return mix( d2, d1, h ) - k*h*(1.0-h);
    }
    
    float scene(vec3 p) {
      float d1 = sdSphere(p, 1., vec3(0, -1, 0));
      float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
      return opSmoothUnion(d1, d2, 0.2);
    }
    

    Interscetion: 取两个图形的相交部分

      float opIntersection(float d1, float d2) {
      return max(d1,d2);
    }
    
    float scene(vec3 p) {
      float d1 = sdSphere(p, 1., vec3(0, -1, 0));
      float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
      return opIntersection(d1, d2);
    }
    

    Smooth Intersection:结合两个物体,并且使用k值来决定边缘的融合程度。0表示不融合。

      float opSmoothIntersection(float d1, float d2, float k) {
      float h = clamp( 0.5 - 0.5*(d2-d1)/k, 0.0, 1.0 );
      return mix( d2, d1, h ) + k*h*(1.0-h);
    }
    
    float scene(vec3 p) {
      float d1 = sdSphere(p, 1., vec3(0, -1, 0));
      float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
      return opSmoothIntersection(d1, d2, 0.2);
    }
    

    裁剪(Subtraction): 用d1裁剪d2

      float opSubtraction(float d1, float d2 ) {
      return max(-d1, d2);
    }
    
    float scene(vec3 p) {
      float d1 = sdSphere(p, 1., vec3(0, -1, 0));
      float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
      return opSubtraction(d1, d2);
    }
    
    

    平滑裁剪(Smooth Subtraction): 用d1裁剪d2,使用平滑的边缘参数k

      float opSmoothSubtraction(float d1, float d2, float k) {
      float h = clamp( 0.5 - 0.5*(d2+d1)/k, 0.0, 1.0 );
      return mix( d2, -d1, h ) + k*h*(1.0-h);
    }
    
    float scene(vec3 p) {
      float d1 = sdSphere(p, 1., vec3(0, -1, 0));
      float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
      return opSmoothSubtraction(d1, d2, 0.2);
    }
    

    反向裁剪2: 用d2裁剪d1.

      float opSubtraction2(float d1, float d2 ) {
      return max(d1, -d2);
    }
    
    float scene(vec3 p) {
      float d1 = sdSphere(p, 1., vec3(0, -1, 0));
      float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
      return opSubtraction2(d1, d2);
    }
    
    

    Smooth Subtraction 2:从d2裁剪d1,使用平滑值k

    float opSmoothSubtraction2(float d1, float d2, float k) {
      float h = clamp( 0.5 - 0.5*(d2+d1)/k, 0.0, 1.0 );
      return mix( d1, -d2, h ) + k*h*(1.0-h);
    }
    
    float scene(vec3 p) {
      float d1 = sdSphere(p, 1., vec3(0, -1, 0));
      float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
      return opSmoothSubtraction2(d1, d2, 0.2);
    }
    
    

    3D定位

    Inigo Quilez's 3D SDFs 的网页上描述了一系列的3D SDF操作,能够帮助我们在绘制3D物体时省下不少时间。有些操作还能提高性能,因为我们不需要重复地运用光线步进函数。

    我们之前学习过如果使用变换矩阵来旋转一个图形,同时将一个3D物体移动到一定距离。如果你需要缩放一个图形,你可以简单地修改SDF的维度即可。

    如果你需要绘制对称的场景,那么你就需要使用opSymx操作。这个方法将会沿着X轴创建一个对称的3D物体。如果你绘制的球在vec3(1,0,0)的位置,那么在vec3(-1, 0, 0)的位置,我们会得到另外一个球;

      float opSymX(vec3 p, float r, vec3 o)
    {
      p.x = abs(p.x);
      return sdSphere(p, r, o);
    }
    
    float scene(vec3 p) {
      return opSymX(p, 1., vec3(1, 0, 0));
    }
    

    如果想要沿着y轴或者z轴做对称效果,那么只需要用p.y或者p.z替换p.x即可。同时要记得同时调整你的偏移值。

    如果你要沿着两个轴而不是一个轴绘制球体,那么你可以使用opSymXZ函数,它分别会在XZ平面上创建一个对象,结果就是出现了四个球。如果我们在vec3(1, 0, 1)的位置上绘制一个球,那么在vec3(1,0,1), vec3(-1,0,1),vec3(1,0,-1)和vec3(-1, 0, -1)位置上都会出现一个球。

      float opSymXZ(vec3 p, float r, vec3 o)
    {
      p.xz = abs(p.xz);
      return sdSphere(p, r, o);
    }
    
    float scene(vec3 p) {
      return opSymXZ(p, 1., vec3(1, 0, 1));
    }
    

    如果想要沿着多个轴创建一个无限数量的3D物体效果,可以使用opRep函数来实现这种效果。参数,c,用来控制在每条轴上物体在3D空间中的间距。

      float opRep(vec3 p, float r, vec3 o, vec3 c)
    {
      vec3 q = mod(p+0.5*c,c)-0.5*c;
      return sdSphere(q, r, o);
    }
    
    float scene(vec3 p) {
      return opRep(p, 1., vec3(0), vec3(8));
    }
    
    

    如果想要在轴上创建出有限数量的3D物体,使用opRepLim函数。参数 c,仍然表示间距,参数 l,表示所在轴上的物体的数量。例如vec3(1,0,1)可以沿着x轴和z轴的正负方向绘制一个球体。

      float opRepLim(vec3 p, float r, vec3 o, float c, vec3 l)
    {
      vec3 q = p-c*clamp(round(p/c),-l,l);
      return sdSphere(q, r, o);
    }
    
    float scene(vec3 p) {
      return opRepLim(p, 0.5, vec3(0), 2., vec3(1, 0, 1));
    }
    
    

    给SDF的计算结果添加p,并且任意修改p,就可以让物体产生形变以及扭曲的效果。在opDisplace函数中,你可以任意的修改这个值来创建各种数学效果。

      float opDisplace(vec3 p, float r, vec3 o)
    {
      float d1 = sdSphere(p, r, o);
      float d2 = sin(p.x)*sin(p.y)*sin(p.z) * cos(iTime);
      return d1 + d2;
    }
    
    float scene(vec3 p) {
      return opDisplace(p, 1., vec3(0));
    }
    

    下面是所示的完整代码:

      const int MAX_MARCHING_STEPS = 255;
    const float MIN_DIST = 0.0;
    const float MAX_DIST = 100.0;
    const float PRECISION = 0.001;
    const float EPSILON = 0.0005;
    const float PI = 3.14159265359;
    const vec3 COLOR_BACKGROUND = vec3(.741, .675, .82);
    const vec3 COLOR_AMBIENT = vec3(0.42, 0.20, 0.1);
    
    mat2 rotate2d(float theta) {
      float s = sin(theta), c = cos(theta);
      return mat2(c, -s, s, c);
    }
    
    float sdSphere(vec3 p, float r, vec3 offset)
    {
      return length(p - offset) - r;
    }
    
    float opUnion(float d1, float d2) { 
      return min(d1, d2);
    }
    
    float opSmoothUnion(float d1, float d2, float k) {
      float h = clamp( 0.5 + 0.5*(d2-d1)/k, 0.0, 1.0 );
      return mix( d2, d1, h ) - k*h*(1.0-h);
    }
    
    float opIntersection(float d1, float d2) {
      return max(d1, d2);
    }
    
    float opSmoothIntersection(float d1, float d2, float k) {
      float h = clamp( 0.5 - 0.5*(d2-d1)/k, 0.0, 1.0 );
      return mix( d2, d1, h ) + k*h*(1.0-h);
    }
    
    float opSubtraction(float d1, float d2) {
      return max(-d1, d2);
    }
    
    float opSmoothSubtraction(float d1, float d2, float k) {
      float h = clamp( 0.5 - 0.5*(d2+d1)/k, 0.0, 1.0 );
      return mix( d2, -d1, h ) + k*h*(1.0-h);
    }
    
    float opSubtraction2(float d1, float d2) {
      return max(d1, -d2);
    }
    
    float opSmoothSubtraction2(float d1, float d2, float k) {
      float h = clamp( 0.5 - 0.5*(d2+d1)/k, 0.0, 1.0 );
      return mix( d1, -d2, h ) + k*h*(1.0-h);
    }
    
    float opSymX(vec3 p, float r, vec3 o)
    {
      p.x = abs(p.x);
      return sdSphere(p, r, o);
    }
    
    float opSymXZ(vec3 p, float r, vec3 o)
    {
      p.xz = abs(p.xz);
      return sdSphere(p, r, o);
    }
    
    float opRep(vec3 p, float r, vec3 o, vec3 c)
    {
      vec3 q = mod(p+0.5*c,c)-0.5*c;
      return sdSphere(q, r, o);
    }
    
    float opRepLim(vec3 p, float r, vec3 o, float c, vec3 l)
    {
      vec3 q = p-c*clamp(round(p/c),-l,l);
      return sdSphere(q, r, o);
    }
    
    float opDisplace(vec3 p, float r, vec3 o)
    {
      float d1 = sdSphere(p, r, o);
      float d2 = sin(p.x)*sin(p.y)*sin(p.z) * cos(iTime);
      return d1 + d2;
    }
    
    float scene(vec3 p) {
      float d1 = sdSphere(p, 1., vec3(0, -1, 0));
      float d2 = sdSphere(p, 0.75, vec3(0, 0.5, 0));
      //return d1;
      //return d2;
      //return opUnion(d1, d2);
      //return opSmoothUnion(d1, d2, 0.2);
      //return opIntersection(d1, d2);
      //return opSmoothIntersection(d1, d2, 0.2);
      //return opSubtraction(d1, d2);
      //return opSmoothSubtraction(d1, d2, 0.2);
      //return opSubtraction2(d1, d2);
      //return opSmoothSubtraction2(d1, d2, 0.2);
      //return opSymX(p, 1., vec3(1, 0, 0));
      //return opSymXZ(p, 1., vec3(1, 0, 1));
      //return opRep(p, 1., vec3(0), vec3(8));
      //return opRepLim(p, 0.5, vec3(0), 2., vec3(1, 0, 1));
      return opDisplace(p, 1., vec3(0));
    }
    
    float rayMarch(vec3 ro, vec3 rd) {
      float depth = MIN_DIST;
      float d; // distance ray has travelled
    
      for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
        vec3 p = ro + depth * rd;
        d = scene(p);
        depth += d;
        if (d < PRECISION || depth > MAX_DIST) break;
      }
      
      d = depth;
      
      return d;
    }
    
    vec3 calcNormal(in vec3 p) {
        vec2 e = vec2(1, -1) * EPSILON;
        return normalize(
          e.xyy * scene(p + e.xyy) +
          e.yyx * scene(p + e.yyx) +
          e.yxy * scene(p + e.yxy) +
          e.xxx * scene(p + e.xxx));
    }
    
    mat3 camera(vec3 cameraPos, vec3 lookAtPoint) {
    	vec3 cd = normalize(lookAtPoint - cameraPos);
    	vec3 cr = normalize(cross(vec3(0, 1, 0), cd));
    	vec3 cu = normalize(cross(cd, cr));
    	
    	return mat3(-cr, cu, -cd);
    }
    
    void mainImage( out vec4 fragColor, in vec2 fragCoord )
    {
      vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
      vec2 mouseUV = iMouse.xy/iResolution.xy;
      
      if (mouseUV == vec2(0.0)) mouseUV = vec2(0.5); // trick to center mouse on page load
    
      vec3 col = vec3(0);
      vec3 lp = vec3(0);
      vec3 ro = vec3(0, 0, 3); // ray origin that represents camera position
      
      float cameraRadius = 2.;
      ro.yz = ro.yz * cameraRadius * rotate2d(mix(-PI/2., PI/2., mouseUV.y));
      ro.xz = ro.xz * rotate2d(mix(-PI, PI, mouseUV.x)) + vec2(lp.x, lp.z);
    
      vec3 rd = camera(ro, lp) * normalize(vec3(uv, -1)); // ray direction
    
      float d = rayMarch(ro, rd); // signed distance value to closest object
    
      if (d > MAX_DIST) {
        col = COLOR_BACKGROUND; // ray didn't hit anything
      } else {
        vec3 p = ro + rd * d; // point discovered from ray marching
        vec3 normal = calcNormal(p); // surface normal
    
        vec3 lightPosition = vec3(0, 2, 2);
        vec3 lightDirection = normalize(lightPosition - p) * .65; // The 0.65 is used to decrease the light intensity a bit
    
        float dif = clamp(dot(normal, lightDirection), 0., 1.) * 0.5 + 0.5; // diffuse reflection mapped to values between 0.5 and 1.0
    
        col = vec3(dif) + COLOR_AMBIENT;    
      }
    
      fragColor = vec4(col, 1.0);
    }
    
    

    总结

    通过本篇教程,我们学习了各种3D物体的形变,例如unionsintersections,以及subtractions等操作。同时学会了使用“positional”方法来在不同的轴上绘制相同的图形。下面的一些资源中,包含了我创建的一个光线步进的模板代码,以及上文中提到的一些3D SDF函数操作。这里讨论的还只是一小部分SDF操作,还有其他的很多操作,你需要访问Inigo Quilez的网站来学习。

    资源

    Ray Marching Template
    3D SDF Operations
    Combination
    Elongation
    Rounding
    Onion
    Metric
    Repetition
    Extrusion2D
    Revolution2D
    Ray Marching Primitives
    Ray Marching Primitives Commented

  • 相关阅读:
    执迷不悟
    splunk设置索引周期和索引大小
    下载地址sqlserver2008r2
    蓝牙
    1、IdentityServer4
    翻译名义集
    sql 字符取数字
    Aerial Images Dataset 航空图像数据集 收集
    基于VGG16模型对猫狗分类任务进行迁移学习
    Apollo配置中心
  • 原文地址:https://www.cnblogs.com/constantince/p/15696642.html
Copyright © 2020-2023  润新知