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

本文转载自 【译】基于 Three.js 实现 3D 模型换肤 · Issue #44 · JChehe/blog

作者:@JChehe

原文: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。在此不深入细节,总之这会让纹理可以保持宽高比不变,保证不会在包裹模型中因各类形状而产生怪异的拉伸(建议你制作自己的模型时才仔细研究它)。

确保所有对象应用 transformations(译者注:即将缩放转为对象实际尺寸)。

文件格式(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 元素(译者注:此行代码可省略)。:

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 在手机上也清晰。(译者注:建议通过监听 window resize 事件进行判断,且设备像素比上限为 2)

在 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 目前仍不支持角度(deg)单位。因此,这里使用 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;</strongto rotate chair. <strong>&nbsp;Scroll&nbsp;</strongto zoom. <strong>&nbsp;Drag&nbsp;</strongswatches 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>

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