前言
效果如下:
本次文章要分享的内容有以下几点:
-
镜面反射
-
文字渲染
1. 镜面反射
本次开发的3D场景,地板具备镜面反射的效果,并且具备低透明度。
1.1 Reflector
threejs官方实例中提供了名为mirror
的Reflector
应用实例。
对场景中的一面墙和地板进行了镜面反射。
其使用方式也极为简单,只需要将geometry传入其中并设置参数即可:
-
let geometry, material;
-
geometry = new THREE.PlaneGeometry( 100, 100 );
-
groundMirror = new Reflector( geometry, {
-
clipBias: 0,
-
textureWidth: window.innerWidth * window.devicePixelRatio,
-
textureHeight: window.innerHeight * window.devicePixelRatio,
-
color: 0x777777,
-
opacity: 0.5,
-
});
-
groundMirror.position.y = 10;
-
groundMirror.rotateX( - Math.PI / 2 );
-
scene.add( groundMirror );
1.2 Reflector的缺陷 - 半透明
将Reflector直接拿来使用是不能满足需求的,因为官方提供的Reflector不具备半透明
的能力。
在实际使用时会像一面完全光滑的镜子
来进行反射。
那么想要实现半透明能力,在理论中有一下几种方法:
-
地板叠在镜面之上,地板半透明
-
镜面叠在地板之上,镜面半透明
-
地板这一物体本身可以进行镜面反射
在最开始项目较急时选择了第1种方案,只需要地板降低透明度就可以达到效果。
在后来阅读了Reflector的源码后选择进行扩展。
1.3 扩展Reflector - 实现镜面半透明
这里选择对Reflector进行扩展,以支撑我们的需求,使镜面实现半透明。
这里对Reflector Shader进行简单修改。
旧 Reflector Shader
Reflector.ReflectorShader = {
uniforms: {
'color': {
value: null
},
'tDiffuse': {
value: null
},
'textureMatrix': {
value: null
}
},
vertexShader: /* glsl */`
uniform mat4 textureMatrix;
varying vec4 vUv;
void main() {
vUv = textureMatrix * vec4( position, 1.0 );
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: /* glsl */`
uniform vec3 color;
uniform sampler2D tDiffuse;
varying vec4 vUv;
float blendOverlay( float base, float blend ) {
return( base < 0.5 ? ( 2.0 * base * blend ) : ( 1.0 - 2.0 * ( 1.0 - base ) * ( 1.0 - blend ) ) );
}
vec3 blendOverlay( vec3 base, vec3 blend ) {
return vec3( blendOverlay( base.r, blend.r ), blendOverlay( base.g, blend.g ), blendOverlay( base.b, blend.b ) );
}
void main() {
vec4 base = texture2DProj( tDiffuse, vUv );
gl_FragColor = vec4( blendOverlay( base.rgb, color ), 1.0 );
}`
};
新 Reflector Shader
Reflector.ReflectorShader = {
uniforms: {
color: {
value: null,
},
tDiffuse: {
value: null,
},
textureMatrix: {
value: null,
},
// 新增uniforms.opacity
opacity: {
value: 1,
},
},
vertexShader: /* glsl */ `
uniform mat4 textureMatrix;
varying vec4 vUv;
void main() {
vUv = textureMatrix * vec4( position, 1.0 );
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: /* glsl */ `
uniform vec3 color;
uniform sampler2D tDiffuse;
uniform float opacity; // 引入opacity
varying vec4 vUv;
float blendOverlay( float base, float blend ) {
return( base < 0.5 ? ( 2.0 * base * blend ) : ( 1.0 - 2.0 * ( 1.0 - base ) * ( 1.0 - blend ) ) );
}
vec3 blendOverlay( vec3 base, vec3 blend ) {
return vec3( blendOverlay( base.r, blend.r ), blendOverlay( base.g, blend.g ), blendOverlay( base.b, blend.b ) );
}
void main() {
vec4 base = texture2DProj( tDiffuse, vUv );
// 使用opacity
gl_FragColor = vec4( blendOverlay( base.rgb, color ), opacity );
}`,
};
此处在原有基础上扩展了opacity参数。
整个镜面物体的透明度将会随着opacity变化。
1.4 改造Reflector - 实现地板反射
第三种方案则是将地板材质与反射进行混合,将二者融为一体,而不是层叠。并通过参数控制反射强度。
首先找一张地板贴图:
改造 Reflector Shader
引入贴图,使用mix进行混合。
Reflector.ReflectorShader = {
uniforms: {
'color': {
value: null
},
'tDiffuse': {
value: null
},
'textureMatrix': {
value: null
},
'opacity': {
value: 1
},
'floorTexture': {
value: null
},
},
vertexShader: /* glsl */`
uniform mat4 textureMatrix;
varying vec4 vUv;
varying vec2 vUv2;
void main() {
vUv2 = uv;
vUv = textureMatrix * vec4( position, 1.0 );
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: /* glsl */`
uniform vec3 color;
uniform sampler2D tDiffuse;
uniform sampler2D floorTexture;
uniform float opacity;
varying vec4 vUv;
varying vec2 vUv2;
float blendOverlay( float base, float blend ) {
return( base < 0.5 ? ( 2.0 * base * blend ) : ( 1.0 - 2.0 * ( 1.0 - base ) * ( 1.0 - blend ) ) );
}
vec3 blendOverlay( vec3 base, vec3 blend ) {
return vec3( blendOverlay( base.r, blend.r ), blendOverlay( base.g, blend.g ), blendOverlay( base.b, blend.b ) );
}
void main() {
vec4 floor = texture2D( floorTexture, vUv2 );
vec4 base = texture2DProj( tDiffuse, vUv );
gl_FragColor = vec4( mix(floor.rgb, blendOverlay( base.rgb, color ), opacity), 1.0 );
}`
};
opacity 参数从 0 - 1 控制镜面反射强度。
效果:
成功实现了某平面的镜面反射。
1.5 其他
扩展Reflector的目的主要是为了实现需求。
分享中只是使用mix函数进行简单的混合。
各位同学在实际开发时可以根据业务需要,针对材质使用对应的混合算法来达到更加拟真的效果。
2. 文字
在日常开发中会有很多的文字加到场景里。
通常使用THREE.Font
和动态生成文字材质
两种文字加载方式。
使用方式有:
-
THREE.TextGeometry
-
THREE.ShapeGeometry
-
THREE.SpriteMaterial
-
THREE.PlaneGeometry
前两种对应THREE.Font
,后两种对应动态生成文字材质
。
本次将会分享几种方法的特点和使用方法。
2.1 THREE.Font
Font对象通常是由THREE.FontLoader
加载生成。
生成后传入到THREE.TextGeometry
中生成立体文字。
或使用Font.generateShapes
生成Shapes数组传入到THREE.ShapeGeometry
使用。
FontLoader.load
则需传入JSON格式的typeface
。
官方在文档中推荐使用facetype.js
来进行转换。
那么在各位同学开发时,遇到没有设计团队或设计师的情况下,无法提供出字体文件时需要自行寻找字体资源。
要注意,字体风格的圆角越多,则字体文件越大。
个别毛笔字字体文件大小高达50m。
本篇文章使用免费的中文字体:字制区喜脉体
来为大家做实例。
难点在于:需要在项目中使用
typeface.js
来进行字体文件
=>THREE.Font
的转换
typeface.js
将typeface的主要代码进行拷贝,并改造
import OpenType from './opentype.min';
const convert = (fontStream, options = {}) => {
// 改变参数,原本是 .checked
const {
filetypeJson = true,
restrictCharactersCheck = false,
reverseTypeface = false,
restrictCharacterSetInput = '',
} = options;
// 手动使用opentype进行转换
const font = OpenType.parse(fontStream);
const scale = (1000 * 100) / ((font.unitsPerEm || 2048) * 72);
const result = {};
result.glyphs = {};
const restriction = {
range: null,
set: null,
};
if (restrictCharactersCheck) {
const restrictContent = restrictCharacterSetInput;
const rangeSeparator = '-';
if (restrictContent.indexOf(rangeSeparator) !== -1) {
const rangeParts = restrictContent.split(rangeSeparator);
if (
rangeParts.length === 2
&& !Number.isNaN(rangeParts[0])
&& !Number.isNaN(rangeParts[1])
) {
restriction.range = [parseInt(rangeParts[0], 10), parseInt(rangeParts[1], 10)];
}
}
if (restriction.range === null) {
restriction.set = restrictContent;
}
}
font.glyphs.forEach((glyph) => {
if (glyph.unicode !== undefined) {
const glyphCharacter = String.fromCharCode(glyph.unicode);
let needToExport = true;
if (restriction.range !== null) {
needToExport = glyph.unicode >= restriction.range[0]
&& glyph.unicode <= restriction.range[1];
} else if (restriction.set !== null) {
needToExport = restrictCharacterSetInput.indexOf(glyphCharacter) !== -1;
}
if (needToExport) {
const token = {};
token.ha = Math.round(glyph.advanceWidth * scale);
token.x_min = Math.round(glyph.xMin * scale);
token.x_max = Math.round(glyph.xMax * scale);
token.o = '';
if (reverseTypeface) {
glyph.path.commands = reverseCommands(glyph.path.commands);
}
glyph.path.commands.forEach((command) => {
if (command.type.toLowerCase() === 'c') {
command.type = 'b';
}
token.o += command.type.toLowerCase();
token.o += ' ';
if (command.x !== undefined && command.y !== undefined) {
token.o += Math.round(command.x * scale);
token.o += ' ';
token.o += Math.round(command.y * scale);
token.o += ' ';
}
if (command.x1 !== undefined && command.y1 !== undefined) {
token.o += Math.round(command.x1 * scale);
token.o += ' ';
token.o += Math.round(command.y1 * scale);
token.o += ' ';
}
if (command.x2 !== undefined && command.y2 !== undefined) {
token.o += Math.round(command.x2 * scale);
token.o += ' ';
token.o += Math.round(command.y2 * scale);
token.o += ' ';
}
});
result.glyphs[String.fromCharCode(glyph.unicode)] = token;
}
}
});
result.familyName = font.familyName;
result.ascender = Math.round(font.ascender * scale);
result.descender = Math.round(font.descender * scale);
result.underlinePosition = Math.round(
font.tables.post.underlinePosition * scale,
);
result.underlineThickness = Math.round(
font.tables.post.underlineThickness * scale,
);
result.boundingBox = {
yMin: Math.round(font.tables.head.yMin * scale),
xMin: Math.round(font.tables.head.xMin * scale),
yMax: Math.round(font.tables.head.yMax * scale),
xMax: Math.round(font.tables.head.xMax * scale),
};
result.resolution = 1000;
result.original_font_information = font.tables.name;
if (font.styleName.toLowerCase().indexOf('bold') > -1) {
result.cssFontWeight = 'bold';
} else {
result.cssFontWeight = 'normal';
}
if (font.styleName.toLowerCase().indexOf('italic') > -1) {
result.cssFontStyle = 'italic';
} else {
result.cssFontStyle = 'normal';
}
if (filetypeJson) {
return JSON.stringify(result);
}
return `if (_typeface_js && _typeface_js.loadFace) _typeface_js.loadFace(${JSON.stringify(
result,
)});`;
};
export default convert;
CustomFontLoader
包装复杂的Loader操作。
import * as THREE from 'three';
import Typeface from './typeface'; // 引入typeface
export default function FontLoader(fontUrl) {
const fileLoader = new THREE.FileLoader(); // 请求字体文件
return new Promise((resolve, reject) => {
fileLoader.setResponseType('blob');
fileLoader.setMimeType('font');
fileLoader.load(
fontUrl,
(data) => {
const reader = new FileReader(); // 将请求的Blob内容转换为ArrayBuffer
reader.addEventListener(
'load',
(event) => {
const font = new THREE.Font(
// 生成font对象
JSON.parse(Typeface(event.target.result)),
);
resolve(font);
},
false,
);
reader.readAsArrayBuffer(data);
},
() => {},
reject,
);
});
}
使用
FontLoader('/static/ChineseText/字制区喜脉体.ttf').then((font) => {
const textGeo = new THREE.TextGeometry(text, {
font,
size: 40,
height: 20,
curveSegments: 4,
bevelThickness: 2,
bevelSize: 1.5,
bevelEnabled: true,
});
textGeo.computeBoundingBox();
const centerOffset = -0.5 * (textGeo.boundingBox.max.x - textGeo.boundingBox.min.x);
const materials = [
new THREE.MeshPhongMaterial({ color: 0xff00ff, flatShading: true }), // front
new THREE.MeshPhongMaterial({ color: 0xffffff }), // side
];
const textMesh1 = new THREE.Mesh(textGeo, materials);
textMesh1.position.x = centerOffset;
textMesh1.position.y = 30;
textMesh1.position.z = 0;
textMesh1.rotation.x = 0;
textMesh1.rotation.y = Math.PI * 2;
scene.add(textMesh1);
});
效果:
2.2 动态生成文字材质
相对于THREE.Font的繁琐,动态生成文字材质更加方便易理解。
-
创建canvas
-
在canvas上写字
-
将canvas变为材质
-
贴给某物体
相较于Font,动态生成的文字不够精细,因为材质是位图。而Font的本质则是矢量图。
想要保持清晰则需要放大生成的canvas和写在其上的文字。
css注册文字
@font-face {
font-family: 'XM-Font';
src: url('/static/ChineseText/字制区喜脉体.ttf');
}
生成材质
function getTextWidth(text, font) {
const span = document.createElement("span");
span.innerText = text;
span.style.font = font;
document.body.appendChild(span);
const rect = span.getBoundingClientRect();
const width = rect.right - rect.left;
span.remove();
return width;
}
function createText(text, fontSize = 40, fontName = 'sans-serif') {
// 先用画布将文字画出
const font = `lighter ${fontSize}px ${fontName}`;
const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
canvas.width = getTextWidth(text, font);
canvas.height = fontSize + 4;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#000";
ctx.font = font;
ctx.lineWidth = 4;
ctx.fillText(text, 0, fontSize);
const texture = new THREE.Texture(canvas);
texture.needsUpdate = true;
const material = new THREE.MeshBasicMaterial({ // 此处可改为SpriteMaterial
map: texture,
alphaTest: 0.1, // 降低文字模糊的几率
transparent: true,
});
const geometry = new THREE.PlaneGeometry(10, 10, 1);
const mesh = new THREE.Mesh(geometry, material);
mesh.scale.set(canvas.width / 4, fontSize / 4, fontSize / 2);
canvas.remove();
return mesh;
}
使用
scene.add(createText('测试文字', 40, 'XM-Font'))
效果: