【译】基于 Three.js 实现 3D 模型换肤

@JChehe 2019-10-26 05:25:08发表于 JChehe/blog

原文:How to Build a Color Customizer App for a 3D Model with Three.js

封面

本文将阐述如何基于 Three.js 创建一个完整的 3D 模型(椅子)颜色自定义应用。

应用截图

马上体验:3D Model Color Customizer App with Three.js

快速介绍

该工具的灵感来源于 Vans shoe customizer,并采用优秀的 JavaScript 3D 库 Three.js 实现。

阅读本文的前提是已掌握 JavaScript、HTML 和 CSS。

为了能让你确切学到东西,而不是单纯地粘贴/复制。本文不按常规出牌,在一开始就给出全部 CSS。CSS 起到装扮应用的作用,即仅专注于 UI。每当我们粘贴部分 HTML 时,都会讲解相应 CSS 的作用。

Part 1: 3D 模型

你可以完全跳过本节,但它可以让你对这一切有更深入的了解。

这不是一篇关于 3D 建模的教程,但我将阐述如何在 Blender 中设置模型,这有助于你创建属于自己的模型、修改网上的免费模型或指点他人调试。以下是创作 3D 模型——椅子的一些经验。

尺寸(Scale)

模型需设置为符合真实世界的尺寸。我也不知道这是否重要,但感觉没问题,为什么不这样做呢?

Scale

分层和命名约定(Layering and naming conventions)

这部分很重要:物体中每个需要独立控制的元素都必须是 3D 场景中独立的对象。这些对象也必须拥有唯一的名字。这里有 back(背部)、base(底座)、cushions(坐垫)、legs(椅腿)和 supports(支架)。若有三个元素都命名为 supports,那么 Blender 会将它们命名为 supportssupports.001supports.002。这没问题,因为我们可以在 JavaScript 中使用 includes("supports") 找到它们。

Layering and naming conventions

落点(Placement)

模型应放置在场景的原点,并落在地板上。另外,最好能面向正确的方向,但这可通过 JavaScript 旋转易实现。

导出设置(Setting up for export)

导出前,要勾选 Blender 的 Smart UV unwrap 选项。在此不深入细节,总之这会让纹理可以保持宽高比不变,保证不会在包裹模型中因各类形状而产生怪异的拉伸(建议你制作自己的模型时才仔细研究该选项)。

译者注:因无 Blender 使用经验,无法翻译以下这段。

You want to be sure to select all of your objects, and apply your transformations. For instance, if you changed the scale or transformed it in any way, you’re telling Blender that this is the new 100% scale, instead of it still being 32.445% scale if you scaled it down a bit.

文件格式(File Format)

显然 Three.js 支持很多 3D 对象文件格式,但它推荐的格式之一是 glTF(.glb)。同时 Blender 也支持导出该格式,所以毫无疑虑。

Part 2:建立环境

Fork 这个 pen(译者注:即 codepen 的一个案例),或创建一个 pen 并从其中复制 CSS。这是一个含有本教程所有 CSS 的空白 pen。

3D Chair Customizer Tutorial - Blank

若不选择 fork,也需要复制 HTML。这包含响应式 meta 标签和 Google 字体。

本教程使用了 3 个依赖,我在它们各自上方写有描述用途的注释。

<!-- The main Three.js file -->
<script src='https://cdnjs.cloudflare.com/ajax/libs/three.js/108/three.min.js'></script>

<!-- This brings in the ability to load custom 3D objects in the .gltf file format. Blender allows the ability to export to this format out the box -->
<script src='https://cdn.jsdelivr.net/gh/mrdoob/Three.js@r92/examples/js/loaders/GLTFLoader.js'></script>

<!-- This is a simple to use extension for Three.js that activates all the rotating, dragging and zooming controls we need for both mouse and touch, there isn't a clear CDN for this that I can find -->
<script src='https://threejs.org/examples/js/controls/OrbitControls.js'></script>

引入 canvas 标签。整个 3D 体验将渲染于此,而其余 HTML 标签作为 UI 辅助于它。将 canvas 放在 HTML 底部(脚本前)。

<!-- The canvas element is used to draw the 3D scene -->
<canvas id="c"></canvas>

现在为 Three.js 创建一个 scene。

// Init the scene
const scene = new THREE.Scene();

接着引用 canvas 元素:

const canvas = document.querySelector('#c');

Three.js 需要具备一些元素才能跑起来。第一个是 scene,第二个是 renderer。在 canvas 变量下方添加它。创建一个 WebGLRenderer,传入 canvas 和选项参数(抗齿距,即使 3D 模型的边缘更光滑)。

// Init the renderer
const renderer = new THREE.WebGLRenderer({canvas, antialias: true});

然后将 renderer 插入到 body 元素(译者注:会将 .wrapper 内的 canvas 迁移至 body,此行代码可省略)。:

document.body.appendChild(renderer.domElement);

为 canvas 编写的 CSS 仅是将其拉伸至 body 的 100% 宽高,因此整个页面目前是黑色的(即 canvas 现在是黑色)。

虽然场景目前漆黑一片,但我们是走在正确的道路上。

接着 Three.js 需要一个更新循环,这是一个在每帧都会执行的函数,对运行我们的应用程序起到重要作用。我们将更新函数命名为 animate(),并将其放置在 JavaScript 代码的最底部。

function animate() {
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

animate();

请注意,上面代码了引用摄像机(camera),但我们仍未添加它。

在 JavaScript 代码顶部,添加一个名为 cameraFar 的变量。当我们添加 camera 到 scene 时,其默认位置是 0,0,0。但这可是椅子的位置!因此 cameraFar 变量是告诉 camera 应离此多远,以确保能看到椅子。

var cameraFar = 5;

animate() 函数上方添加 camera。

// Add a camera
var camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = cameraFar;
camera.position.x = 0;

这是一个透视摄像机,其参数为 50 视场(field of view,fov),宽高比和默认的裁剪区域。裁剪区域指定了可视区域的前后边界。当然,这些都不是本应用关心的事情。(译者注:可参考《Three.js 现学现卖》

我们的场景仍然是黑色,下面设置背景色。

在顶部的 scene 引用上方,添加背景色变量 BACKGROUND_COLOR

const BACKGROUND_COLOR = 0xf1f1f1;

注意我们这里的十六进制是使用 0x 而不是 #。这不是字符串,而是以 0x 开头的整数。

在 scence 引用下方,更新 scene 的背景色,并在远处添加同样颜色的雾,旨在隐藏地板的边界。

const BACKGROUND_COLOR = 0xf1f1f1;

// Init the scene
const scene = new THREE.Scene();

// Set background
scene.background = new THREE.Color(BACKGROUND_COLOR );
scene.fog = new THREE.Fog(BACKGROUND_COLOR, 20, 100);

现在仍是一个空白的世界,没有东西,没有投影。是时候加载模型了。

Part 3:加载模型

我们将使用第二个依赖来加载模型。

在此之前,我们先声明引用模型的变量,该变量会被频繁使用。我们将其放在 JavaScript 顶部的 BACKGROUND_COLOR 前。同时,添加该模型的路径。我已对其进行托管,供大家使用。这有 1Mb 左右的大小。

var theModel;
const MODEL_PATH =  "https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/chair.glb";

现在创建一个 loader,并使用其 load 方法。theModel 就是整个场景的 3D 模型。将其设置合适的尺寸大小,这里设为原大小的 2 倍。接着,设置其 y 轴偏移量为 -1,使其往下移动。最后将其添加到场景中。

load 函数的第一个参数是模型的路径,第二个参数是资源加载后的回调函数,第三个参数目前是 undefined,但它其实是资源加载期间的回调函数,最后一个参数是报错回调函数。

将这部代码放在 camera 下方。

// Init the object loader
var loader = new THREE.GLTFLoader();

loader.load(MODEL_PATH, function(gltf) {
  theModel = gltf.scene;

  // Set the models initial scale   
  theModel.scale.set(2,2,2);

  // Offset the y position a bit
  theModel.position.y = -1;

  // Add the model to the scene
  scene.add(theModel);

}, undefined, function(error) {
  console.error(error)
});

此时,你应该看到的是一张被拉伸、黑色且像素化的椅子。看起来很糟糕,但这是正常的,别担心!

a stretched, black, pixelated chair

除了摄像机,我们还需要光。背景不受光影响,但如果此时添加地板,那么它依然会是黑色。Three.js 有几种类型的光且有丰富的选项进行调整。这里我们添加两种:环境光和定向光。两者的设置是专门适配该应用的,其中包括位置和强度。如果你对它们有使用经验,可以尝试更改。但现在就使用我提供的设置吧。将光放在 loader 下方。

// Add lights
var hemiLight = new THREE.HemisphereLight( 0xffffff, 0xffffff, 0.61 );
    hemiLight.position.set( 0, 50, 0 );
// Add hemisphere light to scene   
scene.add( hemiLight );

var dirLight = new THREE.DirectionalLight( 0xffffff, 0.54 );
    dirLight.position.set( -8, 12, 8 );
    dirLight.castShadow = true;
    dirLight.shadow.mapSize = new THREE.Vector2(1024, 1024);
// Add directional Light to scene    
scene.add( dirLight );

此时,椅子看起来稍微好一些!到目前为止,JavaScript 如下:

var cameraFar = 5;
var theModel;

const MODEL_PATH =  "https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/chair.glb";

const BACKGROUND_COLOR = 0xf1f1f1;
// Init the scene
const scene = new THREE.Scene();
// Set background
scene.background = new THREE.Color(BACKGROUND_COLOR );
scene.fog = new THREE.Fog(BACKGROUND_COLOR, 20, 100);

const canvas = document.querySelector('#c');

// Init the renderer
const renderer = new THREE.WebGLRenderer({canvas, antialias: true});

document.body.appendChild(renderer.domElement);

// Add a camerra
var camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = cameraFar;
camera.position.x = 0;

// Init the object loader
var loader = new THREE.GLTFLoader();

loader.load(MODEL_PATH, function(gltf) {
  theModel = gltf.scene;

  // Set the models initial scale   
  theModel.scale.set(2,2,2);

  // Offset the y position a bit
  theModel.position.y = -1;

  // Add the model to the scene
  scene.add(theModel);

}, undefined, function(error) {
  console.error(error)
});

// Add lights
var hemiLight = new THREE.HemisphereLight( 0xffffff, 0xffffff, 0.61 );
    hemiLight.position.set( 0, 50, 0 );
// Add hemisphere light to scene   
scene.add( hemiLight );

var dirLight = new THREE.DirectionalLight( 0xffffff, 0.54 );
    dirLight.position.set( -8, 12, 8 );
    dirLight.castShadow = true;
    dirLight.shadow.mapSize = new THREE.Vector2(1024, 1024);
// Add directional Light to scene    
scene.add( dirLight );

function animate() {
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

animate();

现在看起来如下:

椅子

让我们解决像素化和拉伸的问题。Three.js 需要在(视口)改变时更新 canvas 尺寸,其内部分辨率需依赖于 canvas 尺寸和设备屏幕像素比(手机的像素比一般比较高)。

在 JavaScript 代码底部,即调用 animate() 的下方添加该函数。该函数会监听 canvas 的尺寸和 window 的尺寸,并返回一个判断两者是否相同的布尔值。我们将会在 animate 函数内使用该函数,以决定是否需要重新渲染场景(设置场景大小)。该函数还会考虑设备像素比,以确保 canvas 在手机上也清晰。

在 JavaScript 底部添加该函数:

function resizeRendererToDisplaySize(renderer) {
  const canvas = renderer.domElement;
  var width = window.innerWidth;
  var height = window.innerHeight;
  var canvasPixelWidth = canvas.width / window.devicePixelRatio;
  var canvasPixelHeight = canvas.height / window.devicePixelRatio;

  const needResize = canvasPixelWidth !== width || canvasPixelHeight !== height;
  if (needResize) {
    renderer.setSize(width, height, false);
  }
  return needResize;
}

现在更新 animate 函数后看起来如下:

function animate() {
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
  
  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement;
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.updateProjectionMatrix();
  }
}

椅子看起来好多了。

our chair is looking so much better

我需要提醒两点:

  • 椅子虽然背对着我们,但可以简单地通过沿其 Y 轴旋转进行调整。
  • 为何支架(supports)是黑色,其余是白色?这是因为导入的模型带有材质(material)信息,这是在 Blender 中进行设置的。没关系,因为我们将添加一个能在程序自定义纹理的函数,支持在模型加载后为椅子不同区域进行设置。如果你有木质和牛仔布纹理(小剧透:本文有),我们就能在加载后立刻进行设置,而无需用户手动选择。因此,椅子目前用什么材质都无关紧要。

跳到 loader 方法,还记得设置缩放比例的地方吗 theModal.scale.set(2,2,2)?我们将调整的旋转角度添加在它下方:

  // Set the models initial scale   
  theModel.scale.set(2,2,2);

  theModel.rotation.y = Math.PI;

哇哦,看起来好多了。还有一件事是:据我所知,Three.js 目前仍不支持角度单位。因此,这里使用 Math.PI,即 180 度,如果想旋转至 45 度角,那么就设置 Math.PI / 4。

旋转 180 度

我们还需要一块地板,不然如何产生投影呢?

创建一个平面(二维平面,或高度为 0 的三维体)作为地板。

在光的下方添加:

// Floor
var floorGeometry = new THREE.PlaneGeometry(5000, 5000, 1, 1);
var floorMaterial = new THREE.MeshPhongMaterial({
  color: 0xff0000,
  shininess: 0
});

var floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -0.5 * Math.PI;
floor.receiveShadow = true;
floor.position.y = -1;
scene.add(floor);

下面讲解一下这里发生了什么。

首先,我们创建了一个几何图形。这是本文创建的唯一一个几何图形,你可以进行各种参数的调整。

第二,我们使用了 MeshPhongMaterial,为其设置了颜色和反光度(shininess)。在讲 Three.js 其他材质前,我们先看看 Phong。我们能调整它的反光度(reflectiveness)和镜面高光(specular highlights)。另外,还有 MeshStandardMaterial,其支持更多的纹理特性,如金属(metallic)和环境光遮蔽(ambient occlusion);另外,还有 MeshBasicMaterial,其不支持阴影。同样,本文仅用到 Phong 材质。

我们创建了变量 floor,并将 geometry 和 material 合为 Mesh。

我们还对地板进行了以下操作:旋转至平坦状态,使其能接收阴影、往下移至椅子的位置。最后将其添加至场景。

现在看起来如下:

添加地板后

我们暂时将地板设为红色,但阴影在哪?为此,我们还需要做几件事。首先在 const renderer 下方添加这几行代码:

// Init the renderer
const renderer = new THREE.WebGLRenderer({canvas, antialias: true});

renderer.shadowMap.enabled = true;
renderer.setPixelRatio(window.devicePixelRatio); 

我们同时设置了设备像素比,尽管这与阴影无关,但恰巧是适当的位置。我们启用了 shadowMap,但仍没有阴影?

在 loader 函数内,我们能遍历 3D 模型(的组成元素)。因此,跳到 loader 函数,在 theModel = gltf.scene; 下添加这个操作。为 3D 模型的每一个元素(椅腿、坐垫等)启用投射和接收阴影的选项。该遍历方法在后续会被再次使用。

theModel = gltf.scene; 下方添加:

  theModel.traverse((o) => {
    if (o.isMesh) {
       o.castShadow = true;
       o.receiveShadow = true;
    }
  });

这看起来比以前更糟,但起码能在地板上产生阴影!之所以不好看,是因为模型仍使用 Blender 带来的材质。下面我们将所有这些材质都替换为普通的 PhongMaterial。

在 loader 函数上方创建另一个 PhongMaterial:

// Initial material
const INITIAL_MTL = new THREE.MeshPhongMaterial( { color: 0xf1f1f1, shininess: 10 } );

这是一个不错的起始材质,灰白色和略带光泽。

虽然目前只有一种材质,但为了方便后续为椅子各个部分设置不同颜色或加载的纹理,我们将材质的数据结构声明为一个数组。

// Initial material
const INITIAL_MTL = new THREE.MeshPhongMaterial( { color: 0xf1f1f1, shininess: 10 } );

const INITIAL_MAP = [
  {childID: "back", mtl: INITIAL_MTL},
  {childID: "base", mtl: INITIAL_MTL},
  {childID: "cushions", mtl: INITIAL_MTL},
  {childID: "legs", mtl: INITIAL_MTL},
  {childID: "supports", mtl: INITIAL_MTL},
];

再次遍历 3D 模型(的组成元素),并使用 childID 查找椅子的不同部分,然后设置相应材质(mtl 属性)。模型每个元素的名字都是在 Blender 中设置的,这在第一节讲到。

在 loader 函数下方,添加一个参数为模型、模型的哪部分(type)和材质的函数。我们还为模型的组成元素添加了一个后续会用到的新属性 nameID

// Function - Add the textures to the models
function initColor(parent, type, mtl) {
  parent.traverse((o) => {
    if (o.isMesh) {
      if (o.name.includes(type)) {
        o.material = mtl;
        o.nameID = type; // Set a new property to identify this object
      }
    }
  });
}

在 loader 函数内的 scene.add(theModel) 前遍历 INITIAL_MAP 数组,并执行该函数,:

  // Set initial textures
  for (let object of INITIAL_MAP) {
    initColor(theModel, object.childID, object.mtl);
  }

最后,回到地板,将其颜色从红色(0xff0000)改为亮灰色(0xeeeeee)。

// Floor
var floorGeometry = new THREE.PlaneGeometry(5000, 5000, 1, 1);
var floorMaterial = new THREE.MeshPhongMaterial({
  color: 0xeeeeee, // <------- Here
  shininess: 0
});

这里值得注意的是:0xeeeeee 与背景色不同。但在光的作用下,它们看起来一致。

3D Chair Customizer Tutorial - Part 1

恭喜,越来越像样了!如果你卡在某一节点,可以 fork 这个 pen 或参考它,直至找到问题所在。

Part 4:添加控制

实际上,这只是一个很小的章节,这得感谢第三个依赖——OrbitControls.js,让这变得十分简单。

在声明 animate 函数上方,添加以下控制代码:

// Add controls
var controls = new THREE.OrbitControls( camera, renderer.domElement );
controls.maxPolarAngle = Math.PI / 2;
controls.minPolarAngle = Math.PI / 3;
controls.enableDamping = true;
controls.enablePan = false;
controls.dampingFactor = 0.1;
controls.autoRotate = false; // Toggle this if you'd like the chair to automatically rotate
controls.autoRotateSpeed = 0.2; // 30

在 animate 函数内的顶部,添加:

  controls.update();

controls 是 OrbitControls 的实例。你可以随意更改其设置,其中包括允许用户旋转椅子(上下)、禁用拖拽以让椅子保持在中心、启用了阻尼使其过渡更自然,还有自动旋转功能(根据个人情况启用与否),但目前是关闭状态。

用你的鼠标或触摸屏进行体验吧!

Scrollable

Part 5:更改颜色

到现在,我们的程序还没进入主题,所以接下来会专注于更改颜色(纹理)。

在 canvas 标签下方添加:

<div class="controls">
<!-- This tray will be filled with colors via JS, and the ability to slide this panel will be added in with a lightweight slider script (no dependency used for this) -->
 <div id="js-tray" class="tray">
     <div id="js-tray-slide" class="tray__slide"></div>
 </div>
</div>

.controls DIV 标签吸附在视口底部,.tray 设为 100%(相对于 body),其子元素 .tray__slide 作为色板,色板可根据需要进行补充。

首先添加几种颜色。在 JavaScript 顶部,添加含有 5 个对象的数组,每个对象都带有 color 属性。

const colors = [
{
    color: '66533C'
},
{
    color: '173A2F'
},
{
    color: '153944'
},
{
    color: '27548D'
},
{
    color: '438AAC'
}  
]

注意:这里的十六进制的颜色值既没有 #,也没有 0x。这是因为它的使用场景不止一种(CSS 和 Three.js)。另外,之所以使用对象,是因为能添加其他属性,如亮度(shininess)、图片纹理。

使用这些颜色制作色板!

首先在 JavaScript 顶部引用滑块:

const TRAY = document.getElementById('js-tray-slide');

在 JavaScript 底部添加一个名为 buildColors 的函数,并立即调用它。

// Function - Build Colors
function buildColors(colors) {
  for (let [i, color] of colors.entries()) {
    let swatch = document.createElement('div');
    swatch.classList.add('tray__swatch');

    swatch.style.background = "#" + color.color;

    swatch.setAttribute('data-key', i);
    TRAY.append(swatch);
  }
}

buildColors(colors);

添加色板

上面我们基于 colors 数组创建了色板列表!注意我们同时为它们设置了 data-key 属性,这是用于查找生成材质的颜色值。

buildColors 函数下方,为色板添加事件处理函数:

// Swatches
const swatches = document.querySelectorAll(".tray__swatch");

for (const swatch of swatches) {
  swatch.addEventListener('click', selectSwatch);
}

点击事件的处理函数命名为 selectSwatch。它会基于色值创建新的 PhongMaterial,并调用另一个函数来遍历 3D 模型(的组成元素),从而对匹配的部分更换材质!

function selectSwatch(e) {
  let color = colors[parseInt(e.target.dataset.key)];
  let new_mtl;

  new_mtl = new THREE.MeshPhongMaterial({
    color: parseInt('0x' + color.color),
    shininess: color.shininess ? color.shininess : 10
  });
    
  setMaterial(theModel, 'legs', new_mtl);
}

该函数通过 data-key 属性匹配颜色,并基于该颜色创建新材质。

该函数仍不能工作,需要添加 setMaterial 函数(在该函数体的最后一行)。

注意:setMaterial(theModel, 'legs', new_mtl); 第二个参数目前暂且传入 'legs',很快我们就有指定不同部分的能力。目前,首先要实现 setMaterial 函数。

在该函数下方,添加 setMaterial 函数:

function setMaterial(parent, type, mtl) {
  parent.traverse((o) => {
    if (o.isMesh && o.nameID != null) {
      if (o.nameID == type) {
        o.material = mtl;
      }
    }
  });
}

这与 initColor 函数大同小异。nameID 属性来自 initColor,若它与参数 type 相等,就为它添加材质。

现在我们的色板能创建新材质,并更改椅腿的颜色,快来试一试!

Swatches change the legs color!

Part 6:指定哪个部分进行更改

我们已经能更改椅腿的颜色。现在就让我们添加指定更改哪部分颜色的能力。在 body 标签内的顶部添加以下 HTML:

<!-- These toggle the the different parts of the chair that can be edited, note data-option is the key that links to the name of the part in the 3D file -->
<div class="options">
    <div class="option --is-active" data-option="legs">
        <img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/legs.svg" alt=""/>
    </div>
    <div class="option" data-option="cushions">
        <img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/cushions.svg" alt=""/>
    </div>
    <div class="option" data-option="base">
        <img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/base.svg" alt=""/>
    </div>
    <div class="option" data-option="supports">
        <img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/supports.svg" alt=""/>
    </div>
    <div class="option" data-option="back">
        <img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/back.svg" alt=""/>
    </div>
</div>

这是带有自定义图标的按钮集合。.option DIV 吸附在视口一侧(另外,通过 CSS 的媒介查询还会使其随着视口大小而进行调整)。每个 .option DIV 都是白色正方形,而带有 --is-active 类名的还会有红色边框。另外,还带有用于匹配 nameID data-option 属性。最后,image 元素拥有 pointer-events 属性,即使点击了 image,点击事件的触发始终保留在其父元素。

指定哪个部分进行更改

在 JavaScript 顶部添加另一个变量 activeOptions,其默认值为 legs

var activeOption = 'legs';

回到 selectSwatch 函数,更改硬编码的 legs 参数为 activeOption

setMaterial(theModel, activeOption, new_mtl);

现在我们需要做的是创建事件处理函数,当点击 .option 时更改 activeOption

const swtachesselectSwatch 函数上方添加:

// Select Option
const options = document.querySelectorAll(".option");

for (const option of options) {
  option.addEventListener('click',selectOption);
}

function selectOption(e) {
  let option = e.target;
  activeOption = e.target.dataset.option;
  for (const otherOption of options) {
    otherOption.classList.remove('--is-active');
  }
  option.classList.add('--is-active');
}

该函数会将 event.targetdata-option 值设为 activeOption,并切换 --is-active 类。

体验一下

Changing options

止步于此?物体全是一种材质类型时,难免乏味。下面就增加木和纺织布材质:

const colors = [
{
    texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/wood.jpg',
    size: [2,2,2],
    shininess: 60
},
{
    texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/denim.jpg',
    size: [3, 3, 3],
    shininess: 0
},
{
    color: '66533C'
},
{
    color: '173A2F'
},
{
    color: '153944'
},
{
    color: '27548D'
},
{
    color: '438AAC'
}  
]

前两个是纹理,分别是木和牛仔布。另外,还增加了两个新属性——sizeshininesssize 表示重复图案的频率,所以数值越大图案越密集。

现在我们要更新两个函数以支持该特性。首先将 buildColors 更新为:

// Function - Build Colors

function buildColors(colors) {
  for (let [i, color] of colors.entries()) {
    let swatch = document.createElement('div');
    swatch.classList.add('tray__swatch');
    
    if (color.texture)
    {
      swatch.style.backgroundImage = "url(" + color.texture + ")";   
    } else
    {
      swatch.style.background = "#" + color.color;
    }

    swatch.setAttribute('data-key', i);
    TRAY.append(swatch);
  }
}

现在它会检查是否存在 texture 属性,若存在,则将色板的背景设为该纹理。

新纹理

注意到第 5、6 块色板之间的间距了吗?我通过 CSS 将每 5 个色板作为一组,这对于拥有更多色板数量时显得尤为重要。

第二个需要更新的函数是 selectSwatch

function selectSwatch(e) {
  let color = colors[parseInt(e.target.dataset.key)];
  let new_mtl;

  if (color.texture) {
    let txt = new THREE.TextureLoader().load(color.texture);

    txt.repeat.set( color.size[0], color.size[1], color.size[2]);
    txt.wrapS = THREE.RepeatWrapping;
    txt.wrapT = THREE.RepeatWrapping;
      
    new_mtl = new THREE.MeshPhongMaterial( {
      map: txt,
      shininess: color.shininess ? color.shininess : 10
    });    
  } 
  else {
    new_mtl = new THREE.MeshPhongMaterial({
      color: parseInt('0x' + color.color),
      shininess: color.shininess ? color.shininess : 10
    });
  }
    
  setMaterial(theModel, activeOption, new_mtl);
}

该函数会检查当前色板是不是纹理,若是,则通过 Three.js 的 TextureLoader 方法创建新纹理,并将该纹理的 repeat 设为色板 size 值。另外,还为纹理设置 wrapping(经试验后得出效果最佳的 wrapping 值),然后为 PhongMaterial 的 map 属性设置为当前纹理,最后设置 shininess 值。

如果当前色板无 texture 属性,则使用老方法。请注意,你也可以为了纯色的材质设置 shininess 值。

设置新材质后的椅子

重要:如果添加纹理后椅子仍表现为黑色,请查看 console,判断是否是跨域导致的问题?这也是 CodePen 的问题,建议注册 Cloudinary 并使用其免费套餐存放图片。

这里带有纹理的 pen:

Texture support

Part 7:收尾工作

我曾有个项目交付给客户验收,这个项目有一个大按钮去祈求被按,甚至在 hover 时还闪闪发光,然而客户及其同事(会计部的 Dave)却反馈他们不知道要按什么(去你的,Dave)。

在 canvas 标签上方添加一些号召性语句:

<!-- Just a quick notice to the user that it can be interacted with -->
<span class="drag-notice" id="js-drag-notice">Drag to rotate 360&#176;</span>

通过 CSS 将其放在椅子前方,用于指示用户可拖拽旋转椅子。但椅子仅仅呆滞不动?

让椅子在首次加载后进行旋转,旋转完毕后隐藏引导语。

首先在 JavaScript 上方添加 loaded 变量,并设为 false

var loaded = false;

在 JavaScript 底部添加该函数:

// Function - Opening rotate
let initRotate = 0;

function initialRotation() {
  initRotate++;
  if (initRotate <= 120) {
    theModel.rotation.y += Math.PI / 60;
  } else {
    loaded = true;
  }
}

模型需要在 120 帧内线性旋转 360 度(约 2 秒,60fps),所以我们将在 animate 函数中运行该函数 120 次,一旦完成则将 loaded 设为 true。代码如下:

function animate() {

  controls.update();
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
  
  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement;
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.updateProjectionMatrix();
  }
  
  if (theModel != null && loaded == false) {
    initialRotation();
  }
}

animate();

我们判断 theModel 是否不等于 nullloaded 是否为 false,若符合条件,则调用该函数 120 次,然后将 loaded 设为 true,使得 animate 函数最终忽略它。

这就拥有了自旋转的椅子。椅子停止的这一刻是删除引导语的好时机。

在 CSS 中,为引导语添加一个带有隐藏动画的类,该动画的延迟时间为 3 秒,所以,在开始旋转椅子的同时为引导语添加该类。

在 JavaScript 顶部引用引导语:

const DRAG_NOTICE = document.getElementById('js-drag-notice');

更新 animate 函数:

  if (theModel != null && loaded == false) {
    initialRotation();
    DRAG_NOTICE.classList.add('start');
  }

好极了!这里有更丰富的颜色供你选择。同时,下方也提供了轻量无依赖的滑动功能(用于拖拽滑动色板列表):

const colors = [
{
    texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/wood_.jpg',
    size: [2,2,2],
    shininess: 60
},
{
    texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/fabric_.jpg',
    size: [4, 4, 4],
    shininess: 0
},
{
    texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/pattern_.jpg',
    size: [8, 8, 8],
    shininess: 10
},
{
    texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/denim_.jpg',
    size: [3, 3, 3],
    shininess: 0
},
{
    texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/quilt_.jpg',
    size: [6, 6, 6],
    shininess: 0
},
{
    color: '131417'  
},
{
    color: '374047'  
},
{
    color: '5f6e78'  
},
{
    color: '7f8a93'  
},
{
    color: '97a1a7'  
},
{
    color: 'acb4b9'  
},
{
    color: 'DF9998',
},
{
    color: '7C6862'
},
{
    color: 'A3AB84'
},
{
    color: 'D6CCB1'
},
{
    color: 'F8D5C4'
},
{
    color: 'A3AE99'
},
{
    color: 'EFF2F2'
},
{
    color: 'B0C5C1'
},
{
    color: '8B8C8C'
},
{
    color: '565F59'
},
{
    color: 'CB304A'
},
{
    color: 'FED7C8'
},
{
    color: 'C7BDBD'
},
{
    color: '3DCBBE'
},
{
    color: '264B4F'
},
{
    color: '389389'
},
{
    color: '85BEAE'
},
{
    color: 'F2DABA'
},
{
    color: 'F2A97F'
},
{
    color: 'D85F52'
},
{
    color: 'D92E37'
},
{
    color: 'FC9736'
},
{
    color: 'F7BD69'
},
{
    color: 'A4D09C'
},
{
    color: '4C8A67'
},
{
    color: '25608A'
},
{
    color: '75C8C6'
},
{
    color: 'F5E4B7'
},
{
    color: 'E69041'
},
{
    color: 'E56013'
},
{
    color: '11101D'
},
{
    color: '630609'
},
{
    color: 'C9240E'
},
{
    color: 'EC4B17'
},
{
    color: '281A1C'
},
{
    color: '4F556F'
},
{
    color: '64739B'
},
{
    color: 'CDBAC7'
},
{
    color: '946F43'
},
{
    color: '66533C'
},
{
    color: '173A2F'
},
{
    color: '153944'
},
{
    color: '27548D'
},
{
    color: '438AAC'
}
]

在 JavaScript 底部添加 slider 函数,它将使你拥有可通过鼠标或触摸屏拖拽色板的能力。为了紧扣主题,这里就不过多研究其工作原理。

var slider = document.getElementById('js-tray'), sliderItems = document.getElementById('js-tray-slide'), difference;

function slide(wrapper, items) {
  var posX1 = 0,
      posX2 = 0,
      posInitial,
      threshold = 20,
      posFinal,
      slides = items.getElementsByClassName('tray__swatch');
  
  // Mouse events
  items.onmousedown = dragStart;
  
  // Touch events
  items.addEventListener('touchstart', dragStart);
  items.addEventListener('touchend', dragEnd);
  items.addEventListener('touchmove', dragAction);


  function dragStart (e) {
    e = e || window.event;
     posInitial = items.offsetLeft;
     difference = sliderItems.offsetWidth - slider.offsetWidth;
     difference = difference * -1;
    
    if (e.type == 'touchstart') {
      posX1 = e.touches[0].clientX;
    } else {
      posX1 = e.clientX;
      document.onmouseup = dragEnd;
      document.onmousemove = dragAction;
    }
  }

  function dragAction (e) {
    e = e || window.event;
    
    if (e.type == 'touchmove') {
      posX2 = posX1 - e.touches[0].clientX;
      posX1 = e.touches[0].clientX;
    } else {
      posX2 = posX1 - e.clientX;
      posX1 = e.clientX;
    }
    
    if (items.offsetLeft - posX2 <= 0 && items.offsetLeft - posX2 >= difference) {
        items.style.left = (items.offsetLeft - posX2) + "px";
    }
  }
  
  function dragEnd (e) {
    posFinal = items.offsetLeft;
    if (posFinal - posInitial < -threshold) { } else if (posFinal - posInitial > threshold) {

    } else {
      items.style.left = (posInitial) + "px";
    }

    document.onmouseup = null;
    document.onmousemove = null;
  }

}

slide(slider, sliderItems);

现在,将 CSS 内的 .tray__slider 小动画注释掉:

/*   transform: translateX(-50%);
  animation: wheelin 1s 2s ease-in-out forwards; */

剩下最后两步收尾工作就完成了!

更新 .controls div,让其包含引导语:

<div class="controls">
<div class="info">
    <div class="info__message">
        <p><strong>&nbsp;Grab&nbsp;</strong> to rotate chair. <strong>&nbsp;Scroll&nbsp;</strong> to zoom. <strong>&nbsp;Drag&nbsp;</strong> swatches to view more.</p>
    </div>
</div>

<!-- This tray will be filled with colors via JS, and the ability to slide this panel will be added in with a lightweight slider script (no dependency used for this) -->
 <div id="js-tray" class="tray">
     <div id="js-tray-slide" class="tray__slide"></div>
 </div>
</div>

现在我们拥有了一个新的信息块,其包含描述如何控制应用的一些说明。

最后,增加一个 loading 遮罩层,以确保在应用加载期间页面是干净的,并在模型加载后将其删除。

在 body 内的顶部增加以下 HTML。

<!-- The loading element overlays all else until the model is loaded, at which point we remove this element from the DOM -->  
<div class="loading" id="js-loader"><div class="loader"></div></div>

为了使其优先加载,我们将这些 CSS 单独放在 head 标签内,而不是链接式的 CSS 中。所以,在 head 闭合标签上方添加以下 CSS。

<style>
.loading {
  position: fixed;
  z-index: 50;
  width: 100%;
  height: 100%;
  top: 0; left: 0;
  background: #f1f1f1;
  display: flex;
  justify-content: center;
  align-items: center;
}

.loader{
  -webkit-perspective: 120px;
  -moz-perspective: 120px;
  -ms-perspective: 120px;
  perspective: 120px;
  width: 100px;
  height: 100px;
}

.loader:before{
  content: "";
  position: absolute;
  left: 25px;
  top: 25px;
  width: 50px;
  height: 50px;
  background-color: #ff0000;
  animation: flip 1s infinite;
}

@keyframes flip {
  0% {
    transform: rotate(0);
  }

  50% {
    transform: rotateY(180deg);
  }

  100% {
    transform: rotateY(180deg)  rotateX(180deg);
  }
}
</style>

快好了!在加载模型后将其删除。

在 JavaScript 顶部引用它:

const LOADER = document.getElementById('js-loader');

loader 函数中,在 scene.add(theModel) 后放置以下代码:

  // Remove the loader
  LOADER.remove();

现在,模型会在该 DIV 背后加载:

loading

就这样!以下就是完整的 pen,仅供参考!

See the Pen 3D Chair Customizer Tutorial - Part 4 by Kyle Wetton (@kylewetton) on CodePen.

<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

你还可以体验托管在 Codrops 上的 案例

感谢您的支持!

这是一篇长篇教程。如果你发现错误,请在评论告诉我。