【夏日特辑】西瓜来咯~

442 阅读8分钟

我正在参加「初夏创意投稿大赛」详情请看:初夏创意投稿大赛

要说什么事物最能代表夏天,第一个想到的就是西瓜了,不过现在的西瓜没有回忆中小时候那么甜了。
今天我们就来画西瓜,一片切开的西瓜~

前段时间在 github 上看到了一个很有意思的 Javascript 伪 3D 引擎,叫做 Zdog,可以绘制扁平风格的 3D 内容。上手简单,虽然有一些缺陷(后面会讲),但是用来做一些小玩具还是很有意思的。

官网有很多优秀的例子,可以参考学习。

先来介绍一下几个我们画西瓜会用到的 API:

Illustration

Illustration 是处理 <canvas> 或者 <svg> 元素的顶级类,保存场景中的所有形状,并在元素中显示这些形状。通过设置元素匹配的选择器来设置目标渲染元素。

const illo = new Illustration({
  element: '#zdogStage', // Canvas、Svg 元素的选择器
  dragRotate: true, // 视角是否可拖动
  zoom: 20, // 数值放大倍数,绘制时的数字将按倍数放大
  rotate: { x: -TAU / 16 }, // 选装状态,可控制 x, y, z 三个维度
  onDragStart: function () { // 拖拽开始事件回调
    ...
  },
});

Shape

Shape 用于绘制自定义形状,通过多个节点连线形成形状,可以设置线条粗细,是否填充来完成各种形状。

平面线条:

new Shape({
  addTo: illo,
  path: [
    { x: -32, y: -40 }, // start at top left
    { x:  32, y: -40 }, // line to top right
    { x: -32, y:  40 }, // line to bottom left
    { x:  32, y:  40 }, // line to bottom right
  ],
  closed: false,
  stroke: 20,
  color: '#636',
});

Z

3D 线条:

new Shape({
  addTo: illo,
  path: [
    { x: -32, y: -40, z:  40 },
    { x:  32, y: -40 },
    { x:  32, y:  40, z:  40 },
    { x:  32, y:  40, z: -40 },
  ],
  closed: false,
  stroke: 20,
  color: '#636',
});

3D Z

Group

一个空的形状,用来组合图形,方便定位。

const eyeGroup = new Group({
  addTo: illo,
  translate: { z: 20 },
});
// eye white first
new Ellipse({
  addTo: eyeGroup,
  width: 160,
  height: 80,
  // ...
});
// then iris
let iris = new Ellipse({
  addTo: eyeGroup,
  diameter: 70,
  // ...
});
// then pupil
iris.copy({
  diameter: 30,
  color: '#636',
});
// highlight last in front
iris.copy({
  diameter: 30,
  translate: { x: 15, y: -15 },
  color: 'white',
});

Group

咱们画西瓜大概就是用到以上这些 API 啦。

首先我们看看一片西瓜的高清大图:

西瓜

从上图可以看出,一片西瓜可以拆分成 7 个部分来画,具体步骤如下:

新建西瓜

html 中插入一个 canvas 标签,id 为 zdogStage,作为渲染元素。接着初始化 Zdog:

import { Illustration, Group, easeInOut, TAU, Shape } from 'zdog';

const illo = new Illustration({
  element: '#zdogStage', // 渲染元素选择器
  dragRotate: true, // 允许拖拽改变视角
  zoom: 20, // 数值放大倍数
});

绿色瓜皮正面

绿色瓜皮正面由 4 个正常节点坐标、2 个弧线顶点坐标绘制而成,注意我们给每个坐标点设置了 Z 轴为 2

const skinFront = new Shape({
  addTo: illo,
  path: [
    { x: -7.1, y: -6, z: 2 }, // 左下角
    { x: -8, y: -8, z: 2 }, // 左上角
    {
      arc: [ // 连接 左上角与右上角的弧线
        { x: 0, y: -12, z: 2 }, // 弧线顶点
        { x: 8, y: -8, z: 2 }, // 右上角
      ]
    },
    { x: 7.1, y: -6, z: 2 }, // 右下角
    {
      arc: [ // 连接 右下角与左下角的弧线
        { x: 0, y: -9.5, z: 2 }, // 弧线顶点
        { x: -7.1, y: -6, z: 2 }, // 左下角
      ]
    },
  ],
  closed: false, // 收尾节点是否自动相连
  color: '#7AB13E', // 线条
  fill: true, // 是否填充线条构成的图形
  stroke: 1 / illo.zoom, // 线条最终粗细为 1
});

绿色瓜皮正面

绿色瓜皮背面

背面的瓜皮与正面瓜皮形状一致,只是在 Z 轴的位置不同。
我们可以使用 Zdog 提供的 API —— copy 来快速完成,在复制的同时设置旋转属性,旋转后实际的 Z 轴位置变成了 -2
因为被复制的对象是添加到 illo,在不修改的情况下,复制出的形状也是添加到 illo

const skinBack = skinFront.copy({
  rotate: { y: TAU / 2 }, // 围绕 Y 轴旋转半圈,TAU 为 Zdog 提供的角度单位,其值可以理解为 360°
})

正面角度无法看出复制后的效果,这边换一个角度展示:

绿色瓜皮背面

青色瓜皮正面 & 背面

青色瓜皮正面与绿色瓜皮正面的绘制方式一致,只是各节点的坐标不同,参照上面的代码可以很快完成绘制。

// 青色瓜皮正面
const insideSkinFront = new Shape({
  addTo: illo,
  path: [
    { x: 6.88, y: -5.5, z: 2 },
    { x: 7.1, y: -6, z: 2 },
    {
      arc: [
        { x: 0, y: -9.5, z: 2 },
        { x: -7.1, y: -6, z: 2 },
      ]
    },
    { x: -6.88, y: -5.5, z: 2 },
    {
      arc: [
        { x: 0, y: -8.8, z: 2 },
        { x: 6.88, y: -5.5, z: 2 }
      ]
    },
  ],
  closed: false,
  color: '#EEEB9A',
  fill: true,
  stroke: 1 / illo.zoom,
});

// 青色瓜皮背面
const insideSkinBack = insideSkinFront.copy({
  rotate: { y: TAU / 2 },
})

青色瓜皮正面 & 背面

绿色瓜皮侧面 & 青色瓜皮侧面

侧面的绘制方法也很简单,找到正面和背面的左侧坐标点,一共 4 个坐标点用线条连接并填充。

右侧的侧面也可以通过复制加旋转来实现。

侧面使用更深一些的绿色,可以让西瓜看起来更立体。

// 左边侧面瓜皮
const skinLeft = new Shape({
  addTo: illo,
  path: [
    { x: -7.1, y: -6, z: 2 }, // 正面左下角
    { x: -8, y: -8, z: 2 }, // 正面左上角
    { x: -8, y: -8, z: -2 }, // 背面左上角
    { x: -7.1, y: -6, z: -2 }, // 背面左下角
  ],
  closed: false,
  color: '#639033',
  fill: true,
  stroke: 1 / illo.zoom,
});

// 右边侧面瓜皮
const skinRight = skinLeft.copy({
rotate: { y: TAU / 2 },
})

// 青色瓜皮左边侧面
const insideSkinLeft = new Shape({
  addTo: illo,
  path: [
    { x: -6.88, y: -5.5, z: 2 },
    { x: -7.1, y: -6, z: 2 },
    { x: -7.1, y: -6, z: -2 },
    { x: -6.88, y: -5.5, z: -2 },
  ],
  closed: false,
  color: '#EEEB9A',
  fill: true,
  stroke: 1 / illo.zoom,
});

// 青色瓜皮右边侧面
const insideSkinRight = insideSkinLeft.copy({
  rotate: { y: TAU / 2 },
})

侧面瓜皮

顶部瓜皮

顶部的瓜皮是弧形的,使用两根弧线加两根直线来绘制一个弯曲的面,把顶部封住。

const topSkin = new Shape({
  addTo: illo,
  path: [
    { x: -8, y: -8, z: -2 }, // 绿色瓜皮背面左上角
    {
      arc: [ // 连接背面左上角与背面右上角的弧线
        { x: 0, y: -12, z: -2 }, // 弧线顶点
        { x: 8, y: -8, z: -2 }, // 绿色瓜皮背面右上角
      ]
    },
    { x: 8, y: -8, z: 2 }, // 正面瓜皮右上角
    {
      arc: [ // 连接正面右上角与正面左上角的弧线
        { x: 0, y: -12, z: 2 }, // 弧线顶点
        { x: -8, y: -8, z: 2 },
      ]
    },
  ],
  closed: true,
  color: '#59812d',
  fill: true,
  stroke: 1 / illo.zoom,
});

顶部瓜皮

其实在做完以后,才发现这个方案存在一些问题,zdog 引擎在渲染弧形面的时候有缺陷,具体看下图:

弧形面缺陷

可以看出,一定角度的俯视、仰视渲染是没有问题的,旋转到侧面会出现渲染异常,目前文档没有相关解决方案,可能是笔者的功夫不到家,嘻嘻。

在演示的时候固定一个不会穿帮的角度,可以获得比较好的视觉效果。

瓜瓤正面 & 瓜籽 & 背面

瓜瓤是个上边弧线的三角形,弧线的参数与青色瓜皮底部的弧线一致。

const melonFront = new Shape({
  addTo: illo,
  path: [
    { x: -6.88, y: -5.5, z: 2 }, // 瓜瓤左顶点
    {
      arc: [ // 连接瓜瓤左顶点与右顶点的弧线
        { x: 0, y: -8.8, z: 2 },
        { x: 6.88, y: -5.5, z: 2 }
      ]
    },
    { x: 0, y: 10, z: 2 },
  ],
  closed: false,
  color: '#FF4846',
  fill: true,
  stroke: 1 / illo.zoom,
});

虽然现在市面上无籽西瓜很多,吃起来更方便,但是在视觉上,西瓜不能没有西瓜籽就像西方不能没有耶路撒冷,让我们给瓜瓤加上瓜籽。

我们先创建一个西瓜籽的分组,并把这个分组添加到瓜瓤正面节点:

const seedGroup = new Group({
  addTo: melonFront,
  translate: { z: 2.3 }, // 模型之间保持距离可以避免过早触发 Z 格斗(渲染层级重叠计算)
});

创建一个西瓜籽形状,使用线条绘制并填充,复制多个摆放在西瓜籽分组的不同位置:

const seed = new Shape({
  addTo: seedGroup,
  path: [
    { x: 0, y: 0 },
    {
      bezier: [ // 贝塞尔曲线
        { x: -0.5, y: 0 },
        { x: -0.5, y: 0.75 },
        { x: 0, y: 1.5 },
      ]
    },
    { x: 0, y: 1.5 },
    {
      bezier: [ // 贝塞尔曲线
        { x: 0.5, y: 0.75 },
        { x: 0.5, y: 0 },
        { x: 0, y: 0 },
      ]
    },
  ],
  stroke: 0.2, // 稍微粗一点的线条可以做出圆角效果
  fill: true,
  color: '#20201E',
});


// 复制多个西瓜籽
seed.copy({
  translate: { y: 2.3, x: -1.2 },
});

seed.copy({
  translate: { y: -3, x: -2.2 },
});

seed.copy({
  translate: { y: -2.3, x: 3.2 },
});

seed.copy({
  translate: { y: -4.3, x: 1.2 },
});

接下来复制瓜瓤正面并旋转,作为瓜瓤背面,这里有个点需要注意,copy 只能复制当前节点,需要使用 copyGraph 来复制当前节点及其子节点。

melonFront.copyGraph({
  rotate: { y: TAU / 2 },
})

瓜瓤正面 & 瓜籽 & 背面

瓜瓤侧面

瓜瓤侧面的绘制方法与瓜皮侧面基本一致,用一个稍微深一些的红色,增加瓜瓤的立体感。

const melonLeft = new Shape({
  addTo: illo,
  path: [
    { x: -6.88, y: -5.5, z: 2 },
    { x: 0, y: 10, z: 2 },
    { x: 0, y: 10, z: -2 },
    { x: -6.88, y: -5.5, z: -2 },
  ],
  closed: false,
  color: '#CC4846',
  fill: true,
  stroke: 1 / illo.zoom,
});

// 红色瓜瓤右侧面
const melonRight = melonLeft.copy({
  rotate: { y: TAU / 2 },
})

瓜瓤侧面

上面提到 Zdog 还有某些缺陷,为了有一个良好的展示效果,我们需要遮遮丑,调整到一个不会露底的视角。

const illo = new Illustration({
  element: '#zdogStage',
  resize: true,
  dragRotate: true,
  zoom: 20,
  rotate: { x: -TAU / 16, y: -TAU / 8 },
});

这样就是俯视的角度啦,效果与上面 👆🏻 的图片类似。

我们可以让西瓜旋转起来,全方位展示立体效果:

// 自动旋转动画
let ticker = 0;
const cycleCount = 240;
const sceneStartRotation = { y: -TAU / 8 };

function animate() {
  if (isSpinning) { // 判断是否自动旋转
    const progress = ticker / cycleCount;
    const theta = easeInOut(progress % 1) * TAU; // 根据上述参数进行缓动计算
    illo.rotate.y = -theta + sceneStartRotation.y;
    ticker++;
  }

  illo.updateRenderGraph(); // 更新视图
  requestAnimationFrame(animate); // 每帧递归调用
}

animate();

西瓜转转

西瓜的演示代码放在了“码上掘金”平台,大家可以体验一下。

Zdog 西瓜演示

Zdog 是一个比较有趣的 3D 引擎,只要你能接受它目前存在的缺陷,可能在生产环境没有啥使用场景,个人拿来做些小玩具还是很有意思的。