目标实现下图的文字特效
Canvas sampling
在canvas上写字,然后对像素采样,属于文字部分的像素点的颜色大于0,记录下坐标,最终得到的坐标集合就作为需要渲染的点的集合。
const textCanvas = document.createElement("canvas");
const textCtx = textCanvas.getContext("2d");
document.body.appendChild(textCanvas);
// 计算宽高
const lines = string.split("\n");
const linesMaxLength = [...lines].sort((a, b) => b.length - a.length)[0]
.length;
const wTexture = textureFontSize * 0.7 * linesMaxLength;
const hTexture = lines.length * textureFontSize;
// 绘制
const lineNumber = lines.length;
textCanvas.width = wTexture;
textCanvas.height = hTexture;
textCtx.font = "100 " + textureFontSize + "px " + fontName;
textCtx.fillStyle = "#2a9d8f";
textCtx.clearRect(0, 0, textCanvas.width, textCanvas.height);
for (let i = 0; i < lineNumber; i++) {
textCtx.fillText(lines[i], 0, ((i + 0.8) * hTexture) / lineNumber);
// 横坐标为0,纵坐标为字左下角
}
const textureCoordinates = [];
const samplingStep = 2;
if (wTexture > 0) {
const imageData = textCtx.getImageData(
0,
0,
textCanvas.width,
textCanvas.height
);
// 获得width*height的一维数组
console.log("-------imageData.length", imageData.data.length);
for (let i = 0; i < textCanvas.height; i += samplingStep) {
for (let j = 0; j < textCanvas.width; j += samplingStep) {
if (imageData.data[(j + i * textCanvas.width) * 4] > 0) {
textureCoordinates.push({
x: j,
y: i,
});
}
}
}
}
Points粒子系统
使用points粒子系统,几何体的数据由上一步采样的坐标得到,纹理可以设置纯色或者图片,最终渲染的效果如下
function createParticles(textureCoordinates, stringBox) {
const geometry = new THREE.BufferGeometry();
const material = new THREE.PointsMaterial({
color: 0xff0000,
size: 1,
});
const vertices = [];
for (let i = 0; i < textureCoordinates.length; i++) {
vertices.push(
textureCoordinates[i].x,
stringBox.hScene -textureCoordinates[i].y,
5 * Math.random()
);
}
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(vertices, 3)
);
const particles = new THREE.Points(geometry, material);
particles.position.x = -0.5 * stringBox.wScene;
particles.position.y = -0.5 * stringBox.hScene;
return particles;
}
instancedMesh
因为points渲染机制只能通过pointsize设置点的大小,如果要将每个点渲染成给定的3d模型就做不到了。使用instancedMesh,可以指定每个实例的几何体,纹理,个数,也就可以指定每个实例的3d渲染方式,然后通过API设置每个实例的变换矩阵就可以高效的渲染多个粒子。
const geo = new THREE.TorusGeometry(0.35, 0.15, 16, 50);
const material = new THREE.MeshNormalMaterial({ });
const instancedMesh = new THREE.InstancedMesh(geo, material, coordinates.length)
instancedMesh.position.x = -.5 * stringBox.wScene
instancedMesh.position.y = -.5 * stringBox.hScene
用户输入
使用contenteditable的div接受用户输入的文字,获取dom的包围盒得到纹理的长宽。contenteditable的div换行后会增加div子元素,第二行的文字就是新增的div的内容,所以为了获取实际的输入文字,需要对innerHTML做一些处理。
得到用户输入后,重新调用生成instancedmesh的方法,去掉旧的实例,将新的instancedmesh加入
document.addEventListener('keyup', () => {
handleInput()
refreshText()
})
function handleInput() {
//...
// 处理得到用户输入
string = textInputEl.innerHTML
.replaceAll("<p>", "\n")
.replaceAll("</p>", "")
.replaceAll("<div>", "\n")
.replaceAll("</div>", "")
.replaceAll("<br>", "")
.replaceAll("<br/>", "")
.replaceAll(" ", " ");
}
光标
光标渲染是一个三维盒子,只要设置好宽高即可,加入到scene。每次输入后,使用range api计算出光标的位置,更新mesh的位置
const cursorGeometry = new THREE.BoxGeometry(.3, 4.5, .03);
cursorGeometry.translate(.5, -2.7, 0)
const cursorMaterial = new THREE.MeshNormalMaterial({
transparent: true,
});
cursorMesh = new THREE.Mesh(cursorGeometry, cursorMaterial);
scene.add(cursorMesh);
function updateCursorPosition() {
cursorMesh.position.x = -.5 * stringBox.wScene + stringBox.caretPosScene[0];
cursorMesh.position.y = .5 * stringBox.hScene - stringBox.caretPosScene[1];
}
计算光标的函数
function getCaretCoordinates() {
const range = window.getSelection().getRangeAt(0);
const needsToWorkAroundNewlineBug = (range.startContainer.nodeName.toLowerCase() === 'div' && range.startOffset === 0);
if (needsToWorkAroundNewlineBug) {
return [
range.startContainer.offsetLeft,
range.startContainer.offsetTop
]
} else {
const rects = range.getClientRects();
if (rects[0]) {
return [rects[0].left, rects[0].top]
} else {
// since getClientRects() gets buggy in FF
document.execCommand('selectAll', false, null);
return [
0, 0
]
}
}
}
光标闪烁
在render函数中不断改变光标的透明度即可。使用THREE的clock获取时间,计算出透明度,当网页聚焦时就设置计算出的透明度,否则设置为0不展示光标
function updateCursorOpacity() {
if (document.hasFocus() && document.activeElement === textInputEl) {
cursorMesh.material.opacity = roundPulse(2 * clock.getElapsedTime());
} else {
cursorMesh.material.opacity = 0;
}
}
let roundPulse = (t) => Math.sign(Math.sin(t * Math.PI)) * Math.pow(Math.sin((t % 1) * 3.14), .2);
动画
目前只有光标闪烁的动画,可以给每个mesh增加旋转、缩放的动画。
前面我们使用坐标给每个mesh设置变换矩阵,接下来我们实现一个类来实现mesh的变换,将坐标传入,该类内部随机生成旋转,缩放,grow函数则更新状态,在生成instancedmesh时,我们使用该类去设置变换矩阵,并且在render函数中调用grow更新状态,重设变换矩阵,就可以给mesh加上动画效果
function Particle([x, y]) {
this.x = x;
this.y = y;
this.z = 0;
this.rotationX = Math.random() * 2 * Math.PI;
this.rotationY = Math.random() * 2 * Math.PI;
this.rotationZ = Math.random() * 2 * Math.PI;
this.scale = 0;
this.deltaRotation = 0.2 * (Math.random() - 0.5);
this.deltaScale = 0.03 + 0.1 * Math.random();
this.toDelete = false;
this.grow = function () {
this.rotationX += this.deltaRotation;
this.rotationY += this.deltaRotation;
this.rotationZ += this.deltaRotation;
if (this.toDelete) {
this.scale -= this.deltaScale;
if (this.scale <= 0) {
this.scale = 0;
}
} else if (this.scale < 1) {
this.scale += this.deltaScale;
}
};
}
输入、删除动画效果
当前每次输入,都会重新生成mesh,并且重新生成记录变换状态的particle实例。我们希望当输入时旧的文字接着上次的动画继续变,而新的文字从小到大变出来,删除的文字从大到小消失。
为了达到这种效果,在生成坐标后,我们需要区分哪些是新的,哪些是旧的坐标。旧的坐标不生成particle,而是返回原有的particle实例,因为是同一个实例,所以状态是上次的状态,动画就能流畅进行,而新的坐标生成新的particle,新生成的particle的scale从0变大,就产生了从小变大的效果。至于删除的坐标,我们先不删除,而是做标记,并且particle类也做上标记,让它的scale从1到0减小,就产生了从大到小消失的效果。而在下一次输入时,我们就删除之前遗留的要被删除的坐标和particle