You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

429 lines
18 KiB

2 years ago
<!DOCTYPE html><html lang="en"><head>
<meta charset="utf-8">
<title>Canvas Textures</title>
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@threejs">
<meta name="twitter:title" content="Three.js – Canvas Textures">
<meta property="og:image" content="https://threejs.org/files/share.png">
<link rel="shortcut icon" href="/files/favicon_white.ico" media="(prefers-color-scheme: dark)">
<link rel="shortcut icon" href="/files/favicon.ico" media="(prefers-color-scheme: light)">
<link rel="stylesheet" href="/manual/resources/lesson.css">
<link rel="stylesheet" href="/manual/resources/lang.css">
<!-- Import maps polyfill -->
<!-- Remove this when import maps will be widely supported -->
<script async src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"></script>
<script type="importmap">
{
"imports": {
"three": "../../build/three.module.js"
}
}
</script>
</head>
<body>
<div class="container">
<div class="lesson-title">
<h1>Canvas Textures</h1>
</div>
<div class="lesson">
<div class="lesson-main">
<p>This article continues from <a href="textures.html">the article on textures</a>.
If you haven't read that yet you should probably start there.</p>
<p>In <a href="textures.html">the previous article on textures</a> we mostly used
image files for textures. Sometimes though we want to generate a texture
at runtime. One way to do this is to use a <a href="/docs/#api/en/textures/CanvasTexture"><code class="notranslate" translate="no">CanvasTexture</code></a>.</p>
<p>A canvas texture takes a <code class="notranslate" translate="no">&lt;canvas&gt;</code> as its input. If you don't know how to
draw with the 2D canvas API on a canvas <a href="https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial">there's a good tutorial on MDN</a>.</p>
<p>Let's make a simple canvas program. Here's one that draws dots at random places in random colors.</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">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);
</pre>
<p>it's pretty straight forward.</p>
<p></p><div translate="no" class="threejs_example_container notranslate">
<div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/canvas-random-dots.html"></iframe></div>
<a class="threejs_center" href="/manual/examples/canvas-random-dots.html" target="_blank">click here to open in a separate window</a>
</div>
<p></p>
<p>Now let's use it to texture something. We'll start with the example of texturing
a cube from <a href="textures.html">the previous article</a>.
We'll remove the code that loads an image and instead use
our canvas by creating a <a href="/docs/#api/en/textures/CanvasTexture"><code class="notranslate" translate="no">CanvasTexture</code></a> and passing it the canvas we created.</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">const cubes = []; // just an array we can use to rotate the 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); // add to our list of cubes to rotate
</pre>
<p>And then call the code to draw a random dot in our render loop</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">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) =&gt; {
const speed = .2 + ndx * .1;
const rot = time * speed;
cube.rotation.x = rot;
cube.rotation.y = rot;
});
renderer.render(scene, camera);
requestAnimationFrame(render);
}
</pre>
<p>The only extra thing we need to do is set the <code class="notranslate" translate="no">needsUpdate</code> property
of the <a href="/docs/#api/en/textures/CanvasTexture"><code class="notranslate" translate="no">CanvasTexture</code></a> to tell three.js to update the texture with
the latest contents of the canvas.</p>
<p>And with that we have a canvas textured cube</p>
<p></p><div translate="no" class="threejs_example_container notranslate">
<div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/canvas-textured-cube.html"></iframe></div>
<a class="threejs_center" href="/manual/examples/canvas-textured-cube.html" target="_blank">click here to open in a separate window</a>
</div>
<p></p>
<p>Note that if you want to use three.js to draw into the canvas you're
better off using a <code class="notranslate" translate="no">RenderTarget</code> which is covered in <a href="rendertargets.html">this article</a>.</p>
<p>A common use case for canvas textures is to provide text in a scene.
For example if you wanted to put a person's name on their character's
badge you might use a canvas texture to texture the badge.</p>
<p>Let's make a scene with 3 people and give each person a badge
or label.</p>
<p>Let's take the example above and remove all the cube related
stuff. Then let's set the background to white and add two <a href="lights.html">lights</a>.</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">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]);
</pre>
<p>Let's make some code to make a label using canvas 2D</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">+function makeLabelCanvas(size, name) {
+ const borderSize = 2;
+ const ctx = document.createElement('canvas').getContext('2d');
+ const font = `${size}px bold sans-serif`;
+ ctx.font = font;
+ // measure how long the name will be
+ const doubleBorderSize = borderSize * 2;
+ const width = ctx.measureText(name).width + doubleBorderSize;
+ const height = size + doubleBorderSize;
+ ctx.canvas.width = width;
+ ctx.canvas.height = height;
+
+ // need to set font again after resizing canvas
+ 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;
+}
</pre>
<p>Then we'll make simple people from a cylinder for the body, a sphere
for the head, and a plane for the label.</p>
<p>First let's make the shared geometry.</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">+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);
</pre>
<p>Then let's make a function to build a person from these
parts.</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">+function makePerson(x, size, name, color) {
+ const canvas = makeLabelCanvas(size, name);
+ const texture = new THREE.CanvasTexture(canvas);
+ // because our canvas is likely not a power of 2
+ // in both dimensions set the filtering appropriately.
+ 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;
+
+ // if units are meters then 0.01 here makes size
+ // of the label into centimeters.
+ const labelBaseScale = 0.01;
+ label.scale.x = canvas.width * labelBaseScale;
+ label.scale.y = canvas.height * labelBaseScale;
+
+ scene.add(root);
+ return root;
+}
</pre>
<p>You can see above we put the body, head, and label on a root
<a href="/docs/#api/en/core/Object3D"><code class="notranslate" translate="no">Object3D</code></a> and adjust their positions. This would let us move the
root object if we wanted to move the people. The body is 2 units
high. If 1 unit equals 1 meter then the code above tries to
make the label in centimeters so they will be size centimeters
tall and however wide is needed to fit the text.</p>
<p>We can then make people with labels</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">+makePerson(-3, 32, 'Purple People Eater', 'purple');
+makePerson(-0, 32, 'Green Machine', 'green');
+makePerson(+3, 32, 'Red Menace', 'red');
</pre>
<p>What's left is to add some <a href="/docs/#examples/controls/OrbitControls"><code class="notranslate" translate="no">OrbitControls</code></a> so we can move
the camera.</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">import * as THREE from 'three';
+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
</pre>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">const fov = 75;
const aspect = 2; // the canvas default
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();
</pre>
<p>and we get simple labels.</p>
<p></p><div translate="no" class="threejs_example_container notranslate">
<div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/canvas-textured-labels.html"></iframe></div>
<a class="threejs_center" href="/manual/examples/canvas-textured-labels.html" target="_blank">click here to open in a separate window</a>
</div>
<p></p>
<p>Some things to notice.</p>
<ul>
<li>If you zoom in the labels get pretty low-res.</li>
</ul>
<p>There is no easy solution. There are more complex font
rendering techniques but I know of no plugin solutions.
Plus they will require the user download font data which
would be slow.</p>
<p>One solution is to increase the resolution of the labels.
Try setting the size passed into to double what it is now
and setting <code class="notranslate" translate="no">labelBaseScale</code> to half what it currently is.</p>
<ul>
<li>The labels get longer the longer the name.</li>
</ul>
<p>If you wanted to fix this you'd instead choose a fixed sized
label and then squish the text.</p>
<p>This is pretty easy. Pass in a base width and scale the text to fit that
width like this</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">-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;
// measure how long the name will be
+ 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;
// need to set font again after resizing canvas
ctx.font = font;
- ctx.textBaseline = 'top';
+ ctx.textBaseline = 'middle';
+ ctx.textAlign = 'center';
ctx.fillStyle = 'blue';
ctx.fillRect(0, 0, width, height);
+ // scale to fit but don't stretch
+ 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;
}
</pre>
<p>Then we can pass in a width for the labels</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">-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');
</pre>
<p>and we get labels where the text is centered and scaled to fit</p>
<p></p><div translate="no" class="threejs_example_container notranslate">
<div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/canvas-textured-labels-scale-to-fit.html"></iframe></div>
<a class="threejs_center" href="/manual/examples/canvas-textured-labels-scale-to-fit.html" target="_blank">click here to open in a separate window</a>
</div>
<p></p>
<p>Above we used a new canvas for each texture. Whether or not to use a
canvas per texture is up to you. If you need to update them often then
having one canvas per texture is probably the best option. If they are
rarely or never updated then you can choose to use a single canvas
for multiple textures by forcing three.js to use the texture.
Let's change the code above to do just that.</p>
<pre class="prettyprint showlinemods notranslate lang-js" translate="no">+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);
// because our canvas is likely not a power of 2
// in both dimensions set the filtering appropriately.
texture.minFilter = THREE.LinearFilter;
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
+ forceTextureInitialization(texture);
...
</pre>
<p></p><div translate="no" class="threejs_example_container notranslate">
<div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/canvas-textured-labels-one-canvas.html"></iframe></div>
<a class="threejs_center" href="/manual/examples/canvas-textured-labels-one-canvas.html" target="_blank">click here to open in a separate window</a>
</div>
<p></p>
<p>Another issue is that the labels don't always face the camera. If you're using
labels as badges that's probably a good thing. If you're using labels to put
names over players in a 3D game maybe you want the labels to always face the camera.
We'll cover how to do that in <a href="billboards.html">an article on billboards</a>.</p>
<p>For labels in particular, <a href="align-html-elements-to-3d.html">another solution is to use HTML</a>.
The labels in this article are <em>inside the 3D world</em> which is good if you want them
to be hidden by other objects where as <a href="align-html-elements-to-3d.html">HTML labels</a> are always on top.</p>
</div>
</div>
</div>
<script src="/manual/resources/prettify.js"></script>
<script src="/manual/resources/lesson.js"></script>
</body></html>