写给设计师的 Processing 编程指南(13) – 3D绘图

三维是二维的进阶。在学习新的知识点前,希望你已经在二维绘图上足够娴熟。

用代码在三维空间绘图,与 3dMax ,Maya 这类三维软件相比,有完全不一样的体验。由于里面没有直观的界面来控制和调整物体的位置,所以写起来会比较抽象。其中有一难点,要熟悉空间坐标系以及对应的各种变换操作。

在平面上绘图只需考虑两个维度,x 轴和 y 轴。但在三维空间中,会多出一个 z 轴。

三维空间中的坐标系

下面先来看三维空间中的坐标分布情况

对比原来的平面坐标系可以发现,x,y 轴的方向与原来是一致。新增加的 z 轴,正方向朝向的是观察者。它表示深度,物体的远近也取决于 z 轴上的数值。

而三维空间中的坐标系原点,与平面二维坐标系的坐标原点位置是一致的。都处于窗口的左上方,上图为了便于演示各个坐标轴的朝向,才对原点进行了平移。下图才是坐标系的默认状态

  • 注:添加了旋转动画效果展示深度

在绘图时,这个坐标系在头脑中必须非常明晰,包括各轴的正方向、负方向。为了加深印象,便于理解。下面再针对具体的坐标点做一些举例。

假设网格之间的距离为 50,某个空间坐标数值为(300,0,0),它便会处于以下位置。

  • 注:坐标原点进行过平移处理

当空间坐标值为 (-300,0,0) 时,便会往左偏移

坐标值为 (0,300,0)

坐标值为 (0,0,300)

坐标值为 (300,300,300)

绘制正方体

前面用了较多篇幅去讲坐标系。这是 3D 绘图的基础。只有熟悉它,才能在空间随心所欲地按自己的构想画出点线面。

下面正式进入绘图部分。从最简单的图形开始,在窗口中绘制一个正方体。

代码示例(13-1):

boolean showWire;

void setup() {
  size(700, 700, P3D);
}

void draw() {
  background(0);

  if (showWire) {
    // 关闭填充,只显示线框
    fill(255);
  } else {
    // 开启填充
    noFill();
    stroke(255);
  }

  translate(width/2, height/2, mouseX);
  strokeWeight(3);
  box(100);
}

void mousePressed() {
  showWire = !showWire;
}

运行效果:

代码说明:

  • Processing  默认绘图采用的二维模式。因此在进行三维绘图前,需要先进行一些设置。size 函数是一个可重载的参数,最后一个参数可以设置成 P3D 或 OPENGL 模式。不同模式会对应不同的渲染器。P3D 最简单,兼容性最强。OPENGL 效率更高,能够使用显卡加速。这里先选择 P3D 进行演示。

  • 例子中设置了一个布尔变量 showWire 用于切换显示模式。当点击鼠标时,showWire 的值会进行取反。从而影响到 draw 函数中的判定语句,实现切换效果

  • translate 的作用大家并不陌生,是对坐标系进行平移。它同时是一个可重载的函数,当输入的参数个数为 3 时,第 3 个参数便会影响 z 轴。前两个参数分别设置成了 width/2 和 height/2。所以坐标原点就恰好居中到屏幕中央,最后的参数由 mouseX 控制。因此左右移动鼠标,可以看到图形的远近发生变化

  • 这里看似物体的位置在移动。但实质上仅仅是坐标系的位置发生了变化,物体的坐标其实仍处于原点

旋转正方体

前面由于摄像机的视角是固定的,物体也没有旋转,所以很不立体,更像是平面的。下面介绍的 rotate() 函数,可以让正方体旋转起来。

rotate 函数有几种变式。分别是 rotateX,rotateY,rotateZ。它们会绕不同的坐标轴进行旋转。

其中,rotate 类的函数始终是以坐标原点为中心进行旋转的。所以若想某物体以自身为中心旋转,需要先使用 translate 将坐标原点变换到原物体的坐标上。

代码示例(13-2):

int rotateMode;

void setup() {
  size(700, 700, P3D);
}

void draw() {
  background(0);

  translate(width/2, height/2);
  if (rotateMode == 0) {
    rotateX(millis() * 0.002);
  } else if (rotateMode == 1) {
    rotateY(millis() * 0.002);
  } else {
    rotateZ(millis() * 0.002);
  }

  noFill();
  strokeWeight(3);
  stroke(255);
  box(300);
}

void mousePressed() {
  rotateMode++;
  if (rotateMode == 3) {
    rotateMode = 0;
  }
}

运行效果:

代码说明:

  • 鼠标单击便可切换不同的旋转模式,分别绕 x 轴,y 轴,z 轴进行旋转。整型变量 rotateMode 作为中介

  • 在绘制三维图形时,translate() 允许只传入两个参数。此时 z 坐标默认值为 0

关于 rotate 的旋转方向

rotate 函数的旋转方向是有固定规律的。当传入的参数为递增时,坐标系便会面朝旋转轴的正方向,进行逆时针旋转。与之相反,递减时则呈顺时针旋转。

下面是数值递增时的旋转情况。

rotateX(millis() * 0.001);

rotateY(millis() * 0.001);

rotateZ(millis() * 0.001);

除了用常规的方式记忆以外,还可以借鉴中学物理课上提到的右手螺旋定则(也叫安培定则)

所谓的右手螺旋定则,是一种判定电流和电流磁场的磁感线方向的方法。右手握住通电导线,大拇指朝向电流方向,其余四指的指向便会对应磁感线方向。

程序中没有电流,没有磁场。但可以用类似的方式来描述方向。例如,当 rotateX 传入的参数递增时,就可以采用“左手螺旋定则”。左手握住旋转的坐标轴 X 轴,大拇指朝向坐标轴的正方向,其余四指的指向便会对应旋转方向。当数值递减时,则采用“右手螺旋定则”,判定方法是一致的。右手握住旋转的坐标轴,大拇指朝向坐标轴的正方向,其余四指的指向便会对应旋转方向。(可对照前面的动图理解此法则)

3D 绘图的相关函数

前面只介绍了一个绘图函数 – box,下面将罗列 Processing 中内置的 3D 绘图函数以及对应的各种重载形式。

函数名 函数功能 参数说明
sphere(float r) 绘制球体 r:球体半径
sphereDetail(float a) 设置球体精度 a:球体精度
sphereDetail(float a,float b) 设置球体精度 a:球体经度精度,b:球体纬度精度
box(float size) 绘制正方体 size:正方体边长
box(float w,float h,float d) 绘制长方体 w、h、d 分别代表长方体的宽度,高度,深度

box 函数,它有两种重载形式。当参数个数为 1 时,数值就代表正方体的边长。参数个数为 3 时,可以分别控制长方体的长宽高。
sphere 函数可以绘制球体,结合 sphereDetail 可以设置球体精度。以上便是 Processing 中仅有的两种内置 3D 函数。

对 3D 绘图而言,仅用这两个函数显然是远远不够的。如果希望绘制一些更复杂的图形,则可以通过自定义函数的方式来实现。由于 3D 图形在程序中本质是由一个个面拼接而成的,所以只要定义好每个面的顶点位置,就能组成一个新的立体图形。下面将提供一个有关圆柱体绘制的函数。只要复制到程序中就可直接调用。

绘制圆柱体

代码示例(13-3):

void setup() {
  size(700, 700, P3D);
}

void draw() {
  background(0);

  translate(width/2, height/2);

  rotateX(millis() * 0.001);
  rotateY(millis() * 0.001);

  stroke(255);
  strokeWeight(3);
  noFill();
  drawCylinder(int(mouseX * 0.05), 120, 350);
}

void drawCylinder(int sides, float r, float h) {
  float angle = 2 * PI / sides;
  float halfHeight = h / 2;
  // 绘制顶面
  beginShape();
  for (int i = 0; i < sides; i++) {
    float x = cos( i * angle ) * r;
    float y = sin( i * angle ) * r;
    vertex( x, y, -halfHeight );
  }
  endShape(CLOSE);
  // 绘制底面
  beginShape();
  for (int i = 0; i < sides; i++) {
    float x = cos( i * angle ) * r;
    float y = sin( i * angle ) * r;
    vertex( x, y, halfHeight );
  }
  endShape(CLOSE);

  // 绘制侧面
  beginShape(TRIANGLE_STRIP);
  for (int i = 0; i < sides + 1; i++) {
    float x = cos( i * angle ) * r;
    float y = sin( i * angle ) * r;
    vertex( x, y, halfHeight);
    vertex( x, y, -halfHeight);
  }
  endShape(CLOSE);
}

运行效果:

代码说明:

  • 函数 drawCylinder 可传入三个参数,其中 sides 代表圆柱的底面精度,r 表示圆柱半径,h 表示圆柱的高

  • 要绘制一个圆柱形,第一步要思考它能划分成哪些体块。理想状态下,圆柱的顶面和底面分别是两个圆形,而侧面,则是一个在空间上弯曲,带有弧度的曲面。但在计算机中,其实不存在“圆形”和“曲面”,它们是由更基本的单元构成的。例如程序中只显示边线的圆,就是由一根根的线段拼接而成,当数量足够多,就会变得平滑,也趋近于圆。而只显示填充色的圆,则是由三角面构成。曲面是同样的道理,构成元素也是三角面,当面数越多,图形就越光滑。

  • 因此,要绘制一个带有体积的图形,重中之重就要考虑如何在空间中绘制三角面。前面有提到过 triangle 函数,但它只能在二维平面上画三角形。如果要在空间中表示,就需必须要使用  beginShape ,endShape 和 vertex 函数。Vertex 是一个可重载函数,当传入的参数个数为 3 时,就表示空间坐标。“绘制顶面”与“绘制底面”的两段代码大家应该不会陌生,用三角函数计算得出圆的各个顶点,前后再用 beginShape 和 endShape 进行包裹,就能表示一个圆。

  • 而“绘制侧面”的代码,里面有一句命令“ beginShape(TRIANGLE_STRIP) ” ,TRIANGLE_STRIP 用于设置顶点的连线模式。它会决定点和点之间,是哪些共同组成一个三角面。当不填任何参数时,默认是根据点的先后顺序,连成一个多边形

  • 当使用 TRIANGLE_STRIP 后,会先将前三个坐标连成一个三角形,之后每增加一个坐标,便会和前两个坐标组成新的三角形。通过这种互相交替的方式,先后连接底面和顶面的顶点,就能生成曲面

  • 这段有关圆柱体的代码无需完全理解,随着学习的深入。会发现有大量的函数和类库是别人已经封装好的,对设计师而已没有必要过度深究这些底层知识。毕竟创作才是最终目的,只要了解调用的方法

除了这些有体积的绘图函数,前面在平面视图下提到过的 line,triangle,rect,ellipse 等函数也能在三维空间中正常显示。只是这类函数大多只有 x ,y 坐标。要表示深度关系,只能结合 translate 函数来对坐标系进行平移。其中 line 函数比较特殊,它允许重载,坐标部分可以多写一个 z 坐标。

函数名 函数功能 参数说明
line(float x1,float y1,float z1,float x2,float y2,float z2) 绘制直线 前三个参数代表直线端点 A 的空间坐标,后三个参数代表端点 B 的空间坐标

3D绘图函数-综合示例01

由于图形的绘制都大同小异,这里就不独立列举每个函数的使用方法。下面整合了一个实例。

代码示例(13-4):

int index;

void setup() {
  size(700,700,P3D);
}

void draw() {
  background(0);

  translate(width/2, height/2);
  rotateX(millis() * 0.001);
  rotateY(millis() * 0.0015);

  noFill();
  stroke(255);
  strokeWeight(3);

  switch (index) {
      case 0:
        box(300);
        break;
      case 1:
        sphereDetail(12);
        sphere(180);
        break;
      case 2:
        line(-200, 0, 200, 0);
        break;
      case 3:
        triangle(150, -200, 0, 150, -150, -200);
        break;
      case 4:
        rectMode(CENTER);
        rect(0, 0, 300, 300);
        break;
      case 5:
        ellipse(0, 0, 400, 400);
        break;
      default:
        break;
  }
}

void keyPressed() {
  if (keyCode == LEFT) {
    index--;
    if (index < 0) {
      index = 5;
    }
  }
  if (keyCode == RIGHT) {
    index++;
    if (index > 5) {
      index = 0;
    }
  }
}

运行效果:

代码说明:

  • rotateX 和 rotateY 的旋转速度略有不同,目的是为了让视角变化更丰富。

  • 为了观察空间体积,使用了 noFill 函数只显示线框

  • LEFT,RIGHT 作为特殊键值,分别代表方向键的左键和右键

  • 变量 index 代表了不同的模式。方向键的左键和右键,可以切换不同的图形

摄像机的控制

下面将介绍摄像机的用法,使用它可以更灵活地控制窗口视角。使用摄像机,需要用到一个名叫 “PeasyCam” 的库。这不是 Processing 中内置的,所以需要到 Libraries 处下载并安装。

安装完毕后,便能使用以下代码

代码示例(13-5):

import peasy.*;
PeasyCam cam;

void setup() {
  size(700,700,P3D);
  cam = new PeasyCam(this, 600);
}
void draw() {
  background(0);
  stroke(255);
  noFill();
  strokeWeight(3);
  box(300);
}

运行效果:

代码说明:

  • 通过 import 导入库

  • 在对 PeasyCam 对象初始化时,默认摄像机是朝向坐标原点的,其中第二个参数表示摄像机到目标的距离。

  • PeasyCam 集成了很多方便的功能,下面是操作方法

操作方式一览:

鼠标左键拖动(笔记本触摸板三指移动):会以原点为目标,旋转摄像机视角

鼠标右键上下拖动(笔记本触摸板双指移动):推拉摄像机,远近变化

鼠标左键拖动 + Command 键:平移摄像机

鼠标左键双击:恢复摄像机初始视角

  • 使用 PeasyCam 有一个好处,坐标原点会默认居中到屏幕中央。这样就无需每次使用 translate(width/2,height/2) 。

灯光系统

谈到三维绘图,不得不提到灯光。要使物体具有立体感,灯光是不可获缺的。Processing 中,就提供多种类型的灯光。如果你之前使用过 3dMax,Maya 这类三维动画软件,一定不会陌生。

在 Processing 中可以使用的灯光类型有这几类。

环境光:光在环境中进行了充分散射,没有光源位置和方向,强度均匀。
平行光:当某个光源距离物体接近无限远,就会产生平行光。在程序中无需设定位置,只有方向,常用于模拟太阳光照
点光源:点光源的光都会从某个点放出,它对各个方向的光照强度是一致的,并会随距离衰减。常用于模拟灯光
聚光灯:聚光灯是一种舞台灯光,投出的灯光近似于锥形。它有多个参数。如角度,集中度等。

下面先以点光源为例。

代码示例(13-6):

  import peasy.*;

  PeasyCam cam;

  void setup() {
    size(700, 700, P3D);
    cam = new PeasyCam(this, 500);
  }
  void draw() {
    background(0);

    // 启用灯光
    pushMatrix();
    pointLight(255, 255, 255, -150, -150, 150);
    rotateX(millis() * 0.0005);
    rotateY(millis() * 0.0005);

    noStroke();
    box(250);
    popMatrix();

    // 绘制灯光位置

    noLights();
    translate(-150, -150, 150);
    fill(255);
    sphere(10);

  }

运行效果:

代码说明:

  • pointLight 函数的作用是在场景中启用点光源。使用时必须放在 draw 函数中,在它之后的绘图函数都会受到点光源的影响,产生光照效果。其中前三个参数代表光源颜色,后三个参数代表点光源的空间坐标

  • 由于灯光本身是不会被程序绘制出来,但为了更直观我们可以手动绘制。例如将灯光的坐标用球体表示。noLights() 函数的作用是关闭灯光,这样球体就不会受灯光影响,并会采用平涂的方式进行着色。若不使用 noLights(),由于程序里点光源的位置恰好处在球心当中,所以外表面就不会被照亮,下面是不使用 noLights() 的效果

  • 有一点值得注意,光源的位置会受 rotate ,translate 等函数的影响。例子中为了让点光源的位置固定在(-150, -150, 150),所以写在 rotateX 和 rotateY 之前

深入灯光系统

下面通过一个综合实例了解各种类型的灯光用法。

代码示例(13-7):

import peasy.*;

PeasyCam cam;
int index, lightMode;

void setup() {
  cam = new PeasyCam(this, 600);
  size(700, 700, P3D);
}

void draw() {
  background(50);

  if (lightMode == 0) {
    ambientLight(255, 0, 0);
  } else if (lightMode == 1) {
    directionalLight(0, 255, 0, mouseX, mouseY, 0);
  } else if (lightMode == 2) {
    // 绘制点光源位置
    pushMatrix();
    translate(-200, -200, 200);
    fill(255);
    sphere(10);
    popMatrix();

    // 开启点光源
    pointLight(0, 0, 255, -200, -200, 200);
  } else if (lightMode == 3) {
    // 绘制聚光灯位置
    pushMatrix();
    translate(0, 0, 300);
    fill(255);
    sphere(10);
    popMatrix();

    // 开启聚光灯
    spotLight(255, 255, 255, 0, 0, 300, 0, 0, -1, mouseX/float(width), mouseY/float(height) * 1000);
  }

  pushMatrix();
  rotateX(millis() * 0.001 * 0.3);
  rotateY(millis() * 0.0015 * 0.3);

  noStroke();

  switch (index) {
  case 0:
    box(300);
    break;
  case 1:
    sphereDetail(200);
    sphere(180);
    break;
  case 2:
    stroke(255);
    strokeWeight(3);
    line(-200, 0, 200, 0);
    break;
  case 3:
    triangle(150, -200, 0, 150, -150, -200);
    break;
  case 4:
    rectMode(CENTER);
    rect(0, 0, 300, 300);
    break;
  case 5:
    ellipse(0, 0, 400, 400);
    break;
  default:
    break;
  }
  popMatrix();
}

void keyPressed() {
  if (keyCode == LEFT) {
    index--;
    if (index < 0) {
      index = 5;
    }
  }
  if (keyCode == RIGHT) {
    index++;
    if (index > 5) {
      index = 0;
    }
  }

  if (keyCode == UP) {
    lightMode--;
    if (lightMode < 0) {
      lightMode = 3;
    }
  }

  if (keyCode == DOWN) {
    lightMode++;
    if (lightMode > 3) {
      lightMode = 0;
    }
  }
}

运行效果:

环境光:

平行光:

点光源:

聚光灯:

代码说明:

  • 键盘方向键的左右键可切换图形,上下键可切换光照模式

  • 环境光,平行光,聚光灯与点光源一样,需要写在 draw 函数中

  • 函数 ambientLight() 可创建环境光。里面的 3 个参数代表光源颜色,分别对应 R,G,B。环境光会从各个方向放出均匀的光线,因此例子中环境光一旦设置成红色,物体表面呈现的颜色就是均匀的。

  • 函数 directionalLight() 可创建平行光。前 3 个参数代表光源颜色,分别对应 R,G,B。后 3 个参数代表光线的指向方向,分别对应 x,y,z 轴。这里使用了 mouseX 和 mouseY 作为参数,因此可以通过移动鼠标来改变光源方向。

  • 点光源和聚光灯是有特定位置的,为了直观地显示。所以在前面使用 sphere 来绘制灯光。

  • 函数 spotLight() 可创建聚光灯。它的参数最为复杂,多达 11 个。前 3 个参数代表光源颜色,分别对应 R,G,B。后面的 3 个参数代表聚光灯的位置,紧跟后面的 3 个参数代表聚光灯的指向,分别对应 x,y,z 轴。倒数第二个参数代表光锥的角度,最后一个参数代表集中度(光线边缘的模糊程度)。

  • 除了 line 函数,其他绘图函数都会受灯光影响。

灯光的综合实例01

代码示例(13-8):

void setup() {
  size(700, 700, P3D);
}

void draw() {
  background(0,50,71);

  translate(width/2, height/2);
  pointLight(255, 0, 255, 200, -200, 200);
  pointLight(0, 255, 255, -200, 200, 200);

  int num = 5;
  float interval = 120;
  float w = (num - 1)* interval;
  for (int i = 0; i < num; i++) {
    for (int j = 0; j < num; j++) {
      pushMatrix();
      translate(-w/2 + i * interval,-w/2 + j * interval);
      rotateX(millis()/1000.0 + i * 0.1);
      rotateY(millis()/1000.0 + j * 0.1);
      noStroke();
      box(65);
      popMatrix();
    }
  }
}

运行效果:

代码说明:

  • 像现实中的灯光一样,程序中的光照效果也是可以叠加的。这里就设置了两个不同颜色,不同位置的点光源,这样可以使物体更有层次感

  • 局部变量 w 代表的是整个矩阵的宽度,通过间距和方体的数量可以计算得出。获得了 w 的数值之后,就可以计算偏移量,让整个矩阵恰好居中到屏幕中央

灯光的综合实例02

代码示例(13-9):

int num;
float []x,y,z;

void setup() {
  size(700, 700, P3D);
  num = 50;
  x = new float[num];
  y = new float[num];
  z = new float[num];
  for (int i = 0; i < num; i++) {
    x[i] = random(-200, 200);
    y[i] = random(-200, 200);
    z[i] = random(-200, 200);
  }
}

void draw() {
  colorMode(RGB);
  background(79, 50, 127);

  translate(width/2, height/2);
  pointLight(255, 0, 0, 200, -200, 200);
  pointLight(255, 0, 255, -200, 200, 200);

  rotateX(millis() / 1000.0);
  rotateY(millis() / 1000.0);

  for (int i = 0; i < num; i++) {
    pushMatrix();
    translate(x[i], y[i], z[i]);
    rotateX(i * 0.2 + millis() * 0.004);
    rotateY(i * 0.3 + millis() * 0.004);
    noStroke();
    box(30,10,90);
    popMatrix();
  }

  for (int i = 0; i < num - 1; i++) {
    if (random(1) < 0.08) {
      colorMode(HSB);
      stroke(random(255),255,255);
      strokeWeight(4);
      line(x[i], y[i], z[i], x[i + 1], y[i + 1], z[i+1]);
    }
  }
}

运行效果:

代码说明:

  • colorMode(HSB) 的作用是将颜色模式设置为 HSB 模式。这样就能控制线条的色相产生随机变化。由于背景色的参数采用的是 RGB 模式,所以需要在开头使用 colorMode(RGB) 将模式切换回 RGB。

  • 第二个 for 循环,作用是对方体之间随机进行连线

灯光系统总结

Processing 的灯光系统有两个特性。一是没有投影,二是物体之间不会相互影响,光没有反射也没有折射。所以在真实感上比不上 Unity 这类游戏引擎,画面的精致程度也很难达到 C4D,Maya 等软件渲染出的效果。

尽管如此,作为一个不那么仿真,阉割版的灯光,只要参数调节得当,还是可以有不错的三维表现力。若是觉得无法满足创作需求了,就可以尝试学习一些更高阶的内容 – Shader Programming , 使用它就不会有以上的限制,可以用 GPU 充分挖掘图形的性能。

若想更多地了解 Shader,推荐 Patricio 的入门教程(http://thebookofshaders.com/),中文版(http://thebookofshaders.com/?lan=ch)

shadertoy 网站可以查看更多绚丽作品,你能从中领略 shader 的魅力。(https://www.shadertoy.com/)

END

至此,基础部分已经完结。回顾前半部分的写作。整个过程并不轻松,作为基础教程,必须在有条件限制的情况下将核心概念讲清楚。保证通俗的同时,实例要简炼有趣。作为初稿,自我评价还算过关。

编程技能的修炼之路上,遵循某种“二八定律”。只要掌握 20 % 的核心概念,就能解决 80 % 的问题。虽然上部分涉及的知识点还远远不到 20 %,但只要把基础巩固好。就能更游刃有余地往下深入。

后面会开始进入新的章节。不谈技术的细枝末节,更多地围绕图形创作展开。敬请期待~

未经允许不得转载:前端撸码笔记 » 写给设计师的 Processing 编程指南(13) – 3D绘图

上一篇:

下一篇: