这篇文章是此篇 关于纹理 文章的延续,如果你还没有读过,你或许应当从那篇开始。
在上一篇讲解纹理的文章中,我们主要使用图像文件来生成动态纹理,有时候我们想在运行时生成一个纹理。一种可行的方式是使用 CanvasTexture。
Canvas纹理 使用一个<canvas> 作为它的输入, 如果你还不知道如何使用2D Canvas API来在画布上绘制内容,MDN上有一篇很好的文章。
我们来写一段简单的Canvas代码,这是一个在随机位置上绘制随机颜色的点的程序。
const ctx = document.createElement('canvas').getContext('2d');
document.body.appendChild(ctx.canvas);
ctx.canvas.width = 256;
ctx.canvas.height = 256;
ctx.fillStyle = '#FFF';
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
function randInt(min, max) {
if (max === undefined) {
max = min;
min = 0;
}
return Math.random() * (max - min) + min | 0;
}
function drawRandomDot() {
ctx.fillStyle = `#${randInt(0x1000000).toString(16).padStart(6, '0')}`;
ctx.beginPath();
const x = randInt(256);
const y = randInt(256);
const radius = randInt(10, 64);
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
function render() {
drawRandomDot();
requestAnimationFrame(render);
}
requestAnimationFrame(render);
这实在太简单了。
现在让我们用它来绘制纹理。我们会用从 上一篇文章 中绘制立方体纹理的例子开始。
我们将删除加载图像的代码,取而代之的是使用我们的Canvas,通过创建一个CanvasTexture,然后把我们创建好的Canvas对象传入。
const cubes = []; // 我们使用这个数组来旋转这些立方体
-const loader = new THREE.TextureLoader();
-
+const ctx = document.createElement('canvas').getContext('2d');
+ctx.canvas.width = 256;
+ctx.canvas.height = 256;
+ctx.fillStyle = '#FFF';
+ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
+const texture = new THREE.CanvasTexture(ctx.canvas);
const material = new THREE.MeshBasicMaterial({
- map: loader.load('resources/images/wall.jpg'),
+ map: texture,
});
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
cubes.push(cube); // 添加到cube list中方便旋转
然后调用代码,在我们的渲染循环中绘制一个随机点。
function render(time) {
time *= 0.001;
if (resizeRendererToDisplaySize(renderer)) {
const canvas = renderer.domElement;
camera.aspect = canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix();
}
+ drawRandomDot();
+ texture.needsUpdate = true;
cubes.forEach((cube, ndx) => {
const speed = .2 + ndx * .1;
const rot = time * speed;
cube.rotation.x = rot;
cube.rotation.y = rot;
});
renderer.render(scene, camera);
requestAnimationFrame(render);
}
我们只需要做额外的一件事,设置了 CanvasTexture 的 needsUpdate属性来告诉THREE.js来更新纹理画布的最新内容。
这样,我们就有了一个用Canvas绘制纹理的立方体。
请注意,如果你想使用THREE.js绘制到Canvas中,你最好用 RenderTarget,在 这篇文章 中有提到。
纹理画布的一个常见用法是在场景中绘制文本。例如,你想把一个人的名字放在他们角色上面作为一个徽标(Badge),你也许需要使用Canvas来绘制徽标纹理。
让我们创建一个有3个人的场景,并给每个人绘制一个徽标或者标签(Label)。
让我们用上面的例子,移除所有相关的立方体。然后设置背景为白色,然后添加两个灯光。
const scene = new THREE.Scene();
+scene.background = new THREE.Color('white');
+
+function addLight(position) {
+ const color = 0xFFFFFF;
+ const intensity = 1;
+ const light = new THREE.DirectionalLight(color, intensity);
+ light.position.set(...position);
+ scene.add(light);
+ scene.add(light.target);
+}
+addLight([-3, 1, 1]);
+addLight([ 2, 1, .5]);
让我们写一些代码以使用2D Canvas绘制标签
+function makeLabelCanvas(size, name) {
+ const borderSize = 2;
+ const ctx = document.createElement('canvas').getContext('2d');
+ const font = `${size}px bold sans-serif`;
+ ctx.font = font;
+ // 测量一下name有多长
+ const doubleBorderSize = borderSize * 2;
+ const width = ctx.measureText(name).width + doubleBorderSize;
+ const height = size + doubleBorderSize;
+ ctx.canvas.width = width;
+ ctx.canvas.height = height;
+
+ // 注意,调整画布后需要重新修改字体
+ ctx.font = font;
+ ctx.textBaseline = 'top';
+
+ ctx.fillStyle = 'blue';
+ ctx.fillRect(0, 0, width, height);
+ ctx.fillStyle = 'white';
+ ctx.fillText(name, borderSize, borderSize);
+
+ return ctx.canvas;
+}
然后我们将用一个圆柱体作为身体,一个球体作为头部,一个平面作为标签来制作一个简单的人。
首先我们开始制作共享几何体。
+const bodyRadiusTop = .4; +const bodyRadiusBottom = .2; +const bodyHeight = 2; +const bodyRadialSegments = 6; +const bodyGeometry = new THREE.CylinderGeometry( + bodyRadiusTop, bodyRadiusBottom, bodyHeight, bodyRadialSegments); + +const headRadius = bodyRadiusTop * 0.8; +const headLonSegments = 12; +const headLatSegments = 5; +const headGeometry = new THREE.SphereGeometry( + headRadius, headLonSegments, headLatSegments); + +const labelGeometry = new THREE.PlaneGeometry(1, 1);
然后我们写一个函数把这些部分组合成一个人。
+function makePerson(x, size, name, color) {
+ const canvas = makeLabelCanvas(size, name);
+ const texture = new THREE.CanvasTexture(canvas);
+ // 因为我们的Canvas长宽都不太可能是2的倍数,所以将filtering设置合理一些
+ texture.minFilter = THREE.LinearFilter;
+ texture.wrapS = THREE.ClampToEdgeWrapping;
+ texture.wrapT = THREE.ClampToEdgeWrapping;
+
+ const labelMaterial = new THREE.MeshBasicMaterial({
+ map: texture,
+ side: THREE.DoubleSide,
+ transparent: true,
+ });
+ const bodyMaterial = new THREE.MeshPhongMaterial({
+ color,
+ flatShading: true,
+ });
+
+ const root = new THREE.Object3D();
+ root.position.x = x;
+
+ const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
+ root.add(body);
+ body.position.y = bodyHeight / 2;
+
+ const head = new THREE.Mesh(headGeometry, bodyMaterial);
+ root.add(head);
+ head.position.y = bodyHeight + headRadius * 1.1;
+
+ const label = new THREE.Mesh(labelGeometry, labelMaterial);
+ root.add(label);
+ label.position.y = bodyHeight * 4 / 5;
+ label.position.z = bodyRadiusTop * 1.01;
+
+ // 如果单位是米, 那这里0.01就是将标签的尺寸转化为厘米
+ const labelBaseScale = 0.01;
+ label.scale.x = canvas.width * labelBaseScale;
+ label.scale.y = canvas.height * labelBaseScale;
+
+ scene.add(root);
+ return root;
+}
在上面你可以看到,我们把身体、头部、标签放在了一个根Object3D 上并且调整了他们的位置。这样如果我们想移动人的话直接移动根对象就可以了。身体是2个单位的高度,如果1个单位等于1米,那么上面的代码会尝试用厘米为单位制作标签,它们使用厘米作为宽高,以更好的适合文本。
然后我们可以制作带标签的人
+makePerson(-3, 32, 'Purple People Eater', 'purple'); +makePerson(-0, 32, 'Green Machine', 'green'); +makePerson(+3, 32, 'Red Menace', 'red');
剩下的就是添加 OrbitControls 这样我们就可以移动相机了。
import * as THREE from 'three';
+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
const fov = 75; const aspect = 2; // Canvas默认值 const near = 0.1; -const far = 5; +const far = 50; const camera = new THREE.PerspectiveCamera(fov, aspect, near, far); -camera.position.z = 2; +camera.position.set(0, 2, 5); +const controls = new OrbitControls(camera, canvas); +controls.target.set(0, 2, 0); +controls.update();
然后我们得到了一些简单的标签。
注意事项:
没有简单的解决方案,还有更复杂的字体渲染技术,据我所知没有插件可以解决这个问题。另外,还需要用户下载字体数据文件,这会变得很慢。
一种方案是增加标签的分辨率,尝试让尺寸变成现在的2倍,然后设置 labelBaseScale 是现在的一半。
如果你想解决这个问题,你需要指定标签的固定大小,然后挤压文本。
这很容易做到。传入一个基本宽度并缩放文本以适应。
-function makeLabelCanvas(size, name) {
+function makeLabelCanvas(baseWidth, size, name) {
const borderSize = 2;
const ctx = document.createElement('canvas').getContext('2d');
const font = `${size}px bold sans-serif`;
ctx.font = font;
// 测量一下name有多长
+ const textWidth = ctx.measureText(name).width;
const doubleBorderSize = borderSize * 2;
- const width = ctx.measureText(name).width + doubleBorderSize;
+ const width = baseWidth + doubleBorderSize;
const height = size + doubleBorderSize;
ctx.canvas.width = width;
ctx.canvas.height = height;
// 注意,调整画布后需要重新修改字体
ctx.font = font;
- ctx.textBaseline = 'top';
+ ctx.textBaseline = 'middle';
+ ctx.textAlign = 'center';
ctx.fillStyle = 'blue';
ctx.fillRect(0, 0, width, height);
+ // 缩放以适应,但是不要拉伸
+ const scaleFactor = Math.min(1, baseWidth / textWidth);
+ ctx.translate(width / 2, height / 2);
+ ctx.scale(scaleFactor, 1);
ctx.fillStyle = 'white';
ctx.fillText(name, borderSize, borderSize);
return ctx.canvas;
}
然后我们可以传入预期标签的长度
-function makePerson(x, size, name, color) {
- const canvas = makeLabelCanvas(size, name);
+function makePerson(x, labelWidth, size, name, color) {
+ const canvas = makeLabelCanvas(labelWidth, size, name);
...
}
-makePerson(-3, 32, 'Purple People Eater', 'purple');
-makePerson(-0, 32, 'Green Machine', 'green');
-makePerson(+3, 32, 'Red Menace', 'red');
+makePerson(-3, 150, 32, 'Purple People Eater', 'purple');
+makePerson(-0, 150, 32, 'Green Machine', 'green');
+makePerson(+3, 150, 32, 'Red Menace', 'red');
我们将文本居中并缩放以适应标签的尺寸。
上面我们为每一个纹理使用了单独的Canvas,是否为每个纹理使用单独的Canvas取决于你。如果你需要经常单独更新它们,每个纹理一个Canvas是一个比较好的选择。如果它们很少或者从不更新,那么你可以用一个Canvas,通过THREE.js来生成多个纹理。让我们更改上面的代码来完成这一点。
+const ctx = document.createElement('canvas').getContext('2d');
function makeLabelCanvas(baseWidth, size, name) {
const borderSize = 2;
- const ctx = document.createElement('canvas').getContext('2d');
const font = `${size}px bold sans-serif`;
...
}
+const forceTextureInitialization = function() {
+ const material = new THREE.MeshBasicMaterial();
+ const geometry = new THREE.PlaneGeometry();
+ const scene = new THREE.Scene();
+ scene.add(new THREE.Mesh(geometry, material));
+ const camera = new THREE.Camera();
+
+ return function forceTextureInitialization(texture) {
+ material.map = texture;
+ renderer.render(scene, camera);
+ };
+}();
function makePerson(x, labelWidth, size, name, color) {
const canvas = makeLabelCanvas(labelWidth, size, name);
const texture = new THREE.CanvasTexture(canvas);
// 因为我们的Canvas长宽都不太可能是2的倍数,所以将filtering设置合理一些
texture.minFilter = THREE.LinearFilter;
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
+ forceTextureInitialization(texture);
...
另一个问题是标签并不总是面向相机,如果你使用标签作为徽标,这可能是一件好事。 如果你使用标签来放置3D游戏中玩家的名字,也许你希望标签总是面对相机。 具体内容在 广告牌(Billboards)文章 有覆盖到。
特别是对于标签,另一种解决方案是使用HTML, 本文中的标签是 位于3D场景中 ,如果你想要他们被其他对象遮挡是很好的,因为 HTML 标签 总是在最上层。