- Java锁的逻辑(结合对象头和ObjectMonitor)
- 还在用饼状图?来瞧瞧这些炫酷的百分比可视化新图形(附代码实现)⛵
- 自动注册实体类到EntityFrameworkCore上下文,并适配ABP及ABPVNext
- 基于Sklearn机器学习代码实战
作者:vivo 互联网前端团队- Wei Xing 。
运营活动新玩法层出不穷,web 3D炙手可热,本文将一步步带大家了解如何利用Three.js和Blender来打造一个沉浸式web 3D展览馆.
3D展览馆是什么,先来预览下效果:
看起来像个3D冒险类手游,用户可以操纵屏幕中央的虚拟摇杆,以第一人称视角在房间内自由移动、看展览.
首先介绍一个背景,我们的工作内容是做游戏中心的用户运营活动,会做些好玩的活动让用户参与,并get一些福利.
当时的活动背景是我司一年一度的vivo游戏节,并且元宇宙是大热词。所以做它的原因有几个:
vivo游戏节主题 。
契合元宇宙热点 。
新玩法、新体验 。
用到的组合方案: Three.js + Blender .
why Three.js 。
开源的3D框架有很多,但最常用的有两种:Three.js、Babylon.js,我们只需要从中二选一。分析后发现两者各有优势:
考虑到3D展览馆的几个基本特性:
简单的小型3D场景,没有复杂的交互(对镜头的要求不高) 。
投放在移动设备,需要尽可能小的包体,以提升性能 。
工期短,需要快速上手及更多的案例参考 。
Three.js包体更小、有更多参考案例、上手更快,所以虽然Babylon.js有它的优势,但Three.js更适合这个项目.
why Blender 。
Blender是一款轻量的开源3D建模软件,有很多好用的免费插件,而且Blender能导出GLTF / GLB模型(后面会对GLTF / GLB模型做简介),匹配Three.js的使用方式,整体更简单好用一些.
所以,就是它了.
在进入开发之前,先简单了解Blender和GLTF / GLB模型.
简单了解 Blender 。
首先,Blender大概长这样,图中是设计师交付的3D展览馆稿子。简单理解为,左侧是模型的层次结构,中间是模型的预览效果,右侧是模型的属性面板.
一般来说,作为开发者我们不需要掌握太多Blender相关知识,只需知道如何看懂模型结构、导出GLTF / GLB模型以及烘焙的基本原理即可.
。
GLTF / GLB模型 。
GLTF(Graphics Language Transmission Format)是一种标准的3D模型文件格式,它以JSON的形式存储3D模型信息,例如模型的层次结构、材质、动画、纹理等.
模型中依赖的静态资源,比如图片,可以通过外部URI的方式来引入,也可以转成base64直接插入在GLTF文件中.
它包含两种形式的后缀,分别是 .gltf(JSON/ASCII) 和 .glb(Binary) 。.gltf是以JSON的形式存储信息。.glb则是.gltf的扩展格式,它以二进制的形式存储信息,因此导出的模型体积也更小一些。如果我们不需要通过JSON对.gltf模型进行直接修改,建议使用.glb模型,它更小、加载更快.
Blender导出GLTF / GLB模型 。
在blender中,可以直接将模型导出为GLTF / GLB格式,三种选项的差别不再赘述,我们先简单选择最高效的.glb格式.
有了模型之后,我们可以开始通过Three.js创建场景,并导入这个模型了.
为了防止篇幅过长,这里假设大家已经掌握了Three.js的一些基本语法。文章重点放在如何加载模型,并一步步进行调优和实现最终的3D展览馆效果.
怎么加载一个模型?
(1)创建一个空场景 。
首先创建一个空场景scene,后续所有的模型或材质都会被添加到这个场景中.
import * as THREE from 'three'
// 1. 创建场景
const scene = new THREE.Scene();
// 2. 创建镜头
const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
// 3. 创建Renderer
const renderer = new THREE.WebGLRenderer();
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
(2)导入GLTF / GLB模型 。
通过GLTFLoader导入.glb模型,并添加到场景中.
import GLTFLoader from 'GLTFLoader'
const loader = new GLTFLoader()
loader.load('path/to/gallery.glb',
gltf => {
scene.add(gltf.scene) // 添加到场景中
}
}
(3)开始渲染 。
通过requestAnimationFrame来调用renderer.render方法,开始实时渲染场景.
function animate() {
requestAnimationFrame( animate );
renderer.render( scene, camera );
}
animate();
ok,这样我们就完成了3D模型的导入,但是发现整个场景一片漆黑.
试试加个环境光.
const ambientLight = new THREE.AmbientLight(0xffffff, 1)
scene.add(ambientLight)
。
ok,亮起来了,但是效果依然很差,很劣质.
原因是模型中的材质效果、光源、阴影、环境纹理,这些全都丢失了,所以当我们导入模型时,看到的就是一堆简陋的纯色形状.
所以我们要一步步将这些丢失东西找回,还原设计稿.
接下来一步步还原设计稿.
(1)加上光源 。
查看Blender模型,看到设计稿中添加了一堆点光源、平行光源.
点光源可以理解为房间中的灯泡,光线强弱随着距离衰减; 。
平行光源可以理解为太阳的直射光,它和点光源不同,光线强弱不随着距离衰减.
于是我们也增加一些光源:
// 一些灯光选项
// 如果是平行光则没有distance、decay选项
const lightOptions = [
{
type: 'point', // 灯光类型:1. point点光源、2. directional平行光源
color: 0xfff0bf, // 灯光颜色
intensity: 0.4, // 灯光强度
distance: 30, // 光照距离
decay: 2, // 衰减速度
position: { // 光源位置
x: 2,
y: 6,
z: 0
}
},
...
]
function createLights() {
pointLightOptions.forEach(option => {
const light = option.type === 'point' ?
new THREE.PointLight(option.color, option.intensity, option.distance, option.decay) :
new THREE.DirectionalLight(option.color, option.intensity)
const position = option.position
light.position.set(position.x, position.y, position.z)
scene.add(light)
})
}
createLights()
可以看到场景比之前好了一些,有了光源后,模型变得立体和真实了,多了一些反色的光泽.
但是我们注意到,画面中的logo、长椅的两侧都是黑色的,并且旁边的球体、椅子等都显得不够真实.
所以,我们需要进行下一步调整:调整模型材质、增加环境纹理.
(2)调整模型材质,增加环境纹理 。
先简单了解一下材质和环境纹理.
材质(material) 。
材质就像物体的皮肤,我们可以调整皮肤的光泽、金属度、粗糙度、透明与否等属性,让物体有不同的视觉效果.
一般从blender导出的模型中,已经包含了一些材质属性,但是Three.js中的材质属性和Blender中的属性并非完全的映射关系,模型在导入到Three.js后,效果和设计稿会有差异。这时候我们需要手动调整材质的属性,来达到和设计稿近似的效果.
环境纹理(environment map) 。
环境纹理就是让模型映射周围的环境,让场景或物体更真实。例如我们要渲染一个立方体,把立方体放进一个屋子里,这个屋子的环境就会影响立方体的渲染效果.
比如镜面的物体被贴上环境纹理后,就可以实时反射周围的环境镜像,看起来很real.
设计稿中也是将一个大厅作为了环境纹理,让场景更真实.
环境纹理分为:球形纹理和立方体形纹理。两者都可以,这里我们采用一张大厅的球形纹理作为环境贴图.
以画面中的vivo游戏节logo为例,我们通过调整它的材质和环境纹理,让它变得更真实.
根据在blender中的命名,找到logo模型 。
调整logo的表面粗糙度和金属度 。
加载并设置环境纹理贴图 。
。
const loader = new GLTFLoader()
loader.load('path/to/gallery.glb',
gltf => {
// 1. 根据Blender中物体的名字,找到logo模型
gltf.scene.traverse(child => {
if (isLogo(child)) {
initLogo(child) // 2. 调整材质
setEnvMap(child) // 3. 设置环境纹理
}
})
scene.add(gltf.scene)
}
}
// 判断是否为Logo
const isLogo = object.name === 'logo'
function initLogo(object) {
object.material.roughness = 0 // 调整表面粗糙度
object.material.metalness = 1 // 调整金属度
}
// 加载环境纹理
let envMap
const envmaploader = new THREE.PMREMGenerator(renderer)
const setEnvMap = (object) => {
if(envMap) {
object.material.envMap = envMap.texture
} else {
textureLoader.load('path/to/envMap.jpg',
texture => {
texture.encoding = THREE.sRGBEncoding
envMap = envmaploader.fromCubemap(texture)
object.material.envMap = envMap.texture
})
}
}
经过上面的处理后,可以看到原先黑色的logo有了金属光泽,并且会反射周围的环境纹理.
其它物体经过类似的处理后,也变得更真实一些.
现在整个场景更接近了设计稿一些,但场景中少了阴影,显得很干瘪.
加上阴影.
(3)增加阴影 。
增加阴影分四步:
对renderer开启阴影支持:renderer.shadowMap.enabled = true 。
对光源设置:castShadow = true 。
对需要投影的物体设置:castShadow = true 。
对需要被投影的平面或物体(比如地板)设置:receiveShadow = true 。
// 1. renderer
const renderer = new THREE.WebGLRenderer()
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// 2. light
const light = new THREE.DirectionalLight()
light.castShadow = true;
// 3. object
gltf.scene.traverse(function (child) {
if (child.isMesh) {
child.castShadow = true;
}
});
// 4. floor
floor.receiveShadow = true
添加阴影后,有质的提升,发现整个场景立体了很多,此时还原度已经很高.
如果不考虑性能损耗,这个场景的样式已经可以投入使用了。(后续会提到性能优化) 。
小结一下,刚刚做的几件事:
添加光源 。
调整模型材质、增加环境纹理 。
增加阴影 。
现在3D展览馆场景已经还原的差不多了,接下来要构造一个虚拟移动摇杆,控制第一人称镜头的移动和转向,实现沉浸式逛展的效果.
要实现通过虚拟移动摇杆控制镜头的移动和转向,我们需要三个东西:
一个移动摇杆(handler) 。
一个长方体(player):用于承载第一人称视角 。
一个镜头(camera):之前已经创建过了 。
有人会问为什么需要一个player,通过摇杆直接控制镜头不就行了吗?其实player的作用是用于做碰撞检测,当player遇到凳子、墙壁等障碍物时,需要停止镜头移动。直接控制镜头,是无法做碰撞检测的.
所以,实际上镜头移动的逻辑是:
用户操纵摇杆 → 更新player位置和朝向 →从而同步更新camera位置和朝向 。
(1)创建移动摇杆 。
移动摇杆的实现原理很简单,这里仅做简述.
核心在于创建一个圆盘,监听触摸手势,并根据手势的方向来实时更新move参数,控制镜头的移动和转向.
const speed = 8 // 移动速度
const turnSpeed = 3 // 转向速度
// move option,用于调整第一人称镜头的移动和转向
const move = {
turn: 0, // 旋转角度
forward: 0 // 前进距离
}
// 创建一个handler,并监听手势,调整move option
const handler = new Handler()
handler.onTouchMove = () => { // update move option }
(2)创建player 。
首先创建一个player对象,它是一个1.2 * 2 * 1的透明长方体.
function createPlayer() {
const box = new THREE.BoxGeometry(1.2, 2, 1)
const mat = new THREE.MeshBasicMaterial({
color: 0x000000,
wireframe: true
})
const mesh = new THREE.Mesh(box, mat)
box.translate(0, 1, 0)
return mesh
}
const player = createPlayer() // 创建player
player.position.set(4.5, 2, 12) // 设置player的初始位置
(3)updatePlayer & updateCamera 。
每次渲染(render)时,更新player的位置和朝向,并同步更新镜头的位置和朝向.
const clock = THREE.clock()
function render() {
const dt = clock.delta() // 获取每帧之间的时间间隔,根据时间间隔长短来更新player和camera的移动距离和转向的多少
updatePlayer(dt)
updateCamera(dt)
renderer.render(scene, camera)
window.requestAnimationFrame(render)
}
// 更新player的位置和朝向
function updatePlayer(dt) {
const pos = player.position.clone()
pos.y -= 1.5 // 降低高度,后续用于计算碰撞检测
const dir = new THREE.Vector3()
player.getWorldDirection(dir)
dir.negate()
if (move.forward < 0) dir.negate()
// 调整镜头前进 or 后退
if (move.forward !== 0) {
player.translateZ(move.forward > 0 ? -dt * speed : dt * speed * 0.5)
}
// 调整镜头朝向
if (move.turn !== 0) {
player.rotateY(move.turn * 1.2 * dt)
}
}
// 根据player的位置和朝向,同步更新camera的位置和朝向
function updateCamera(dt) {
camera.position.lerp(activeCamera.getWorldPosition(new THREE.Vector3()), 0.08)
const pos = player.position.clone()
pos.y += 2.5
camera.lookAt(pos)
}
注意 :render方法中使用clock.delta()来计算每次渲染之间的时间间隔,并使用这个时间间隔来更新player和camera。因为在理想的60帧率情况下,两帧时间间隔为16.67ms,但实际上该数值会有波动,因此我们要根据实际的渲染时间间隔来更新player和camera,让镜头的移动和转向幅度更自然一些.
完成上述步骤后,我们就可以通过控制虚拟移动摇杆,来让镜头移动和转向了.
接下来加入碰撞检测,对镜头移动加点限制.
碰撞检测的步骤也很简单:
收集障碍物(colliders) 。
检测碰撞(基于THREE.Raycaster) 。
(1)收集障碍物 。
模型加载完成后,遍历所有的child,如果child是一个物体(mesh),则把它加入到障碍物队列(colliders)中.
const colliders = []
loader.load('path/to/gallery.glb',
gltf => {
gltf.scene.traverse(child => {
// 收集障碍物
if(isMesh(child)) {
colliders.push(child)
}
})
}
})
(2)检测碰撞 。
调整刚刚的updatePlayer方法,在其中插入检测碰撞的逻辑.
碰撞检测逻辑基于THREE.Raycaster来实现,racaster可以理解为一个射线,当射线穿过了某个物体,我们就认为射线和物体相交了.
我们让射线的方向和player的朝向保持一致,并且在移动过程中不断判断射线前方/后面是否有相交的物体,如果有相交的物体,且和射线顶点距离distance < 2.5则认为遇到了障碍物,不能再继续前进.
function updatePlayer(dt) {
const pos = player.position.clone()
pos.y -= 1.5 // 降低高度,用于计算collision
const dir = new THREE.Vector3()
// 获取当前player的朝向
player.getWorldDirection(dir)
dir.negate()
// 如果是向后退,需要对朝向取反
if (move.forward < 0) dir.negate()
// 利用Raycaster判断player是否和colliders有碰撞行为
const raycaster = new THREE.Raycaster(pos, dir)
let blocked = false
if (colliders.length > 0) {
const intersect = raycaster.intersectObjects(colliders)
if (intersect.length > 0) {
// 如果相交距离<2.5,表示前方或后面有障碍物
if (intersect[0].distance < 2.5) {
blocked = true
}
}
}
// 如果遇到障碍物,则停滞移动
if (!blocked) {
// 调整镜头前进 or 后退
if (move.forward !== 0) {
player.translateZ(move.forward > 0 ? -dt * speed : dt * speed * 0.5)
}
}
// 调整镜头朝向
if (move.turn !== 0) {
player.rotateY(move.turn * 1.2 * dt)
}
}
这样镜头的移动和碰撞检测就完成了.
当我们移动到椅子、墙壁等障碍物附近时,镜头会停止移动。镜头的移动范围也被我们限制在房间里,不会穿到房间外部.
3D展览馆的基本功能已经完成了,但还没有做任何的性能调优。当我们把项目运行在手机上,会发现设备发热发烫,帧率很低,低端机型甚至无法运行.
经过分析,实时的光影渲染是罪魁祸首.
页面中有10+个光源,每个光源都在实时投射阴影(尤其是点光源十分消耗资源,引起卡顿)。但实际,场景中的光源和物体位置都没有发生改变,这意味着我们不需要计算实时阴影,只需要固定的阴影.
这点可以通过纹理烘焙来实现。并且在移动端,经过纹理烘焙的光影效果实际上要优于设备计算的实时光影效果.
纹理烘焙(Texture Baking) 。
纹理烘焙,是指通过将场景效果预渲染到指定纹理上,生成一个模型贴图。在Blender中,我们可以选中任意对象进行烘焙.
以3D展览馆的地板为例,我们可以通过纹理烘焙,将光影效果直接渲染到贴图上.
左图是原本的棋盘格纹理,右图是结合了光影效果的烘焙贴图。烘焙完成后,地板上的光影效果就被固定下来了,我们也不需要再做实时的光影渲染.
用同样的方式,将地板、墙壁、天花板等物体,一一进行烘焙处理,导出一个新的模型。由于光影效果已经被渲染到贴图上,我们可以将大部分光源去掉,只保留2-3个必要的点、平行光源和全局光。再次运行后,发现卡顿、发烫的问题已经不再明显。并且效果其实比实时渲染更精细一些.
这里没有对烘焙做过多介绍,要生成精致的烘焙结果还需要依赖对UV Map、烘焙参数的了解,虽然这些偏向于设计同学的工作,一般由他们来输出烘焙纹理。但是作为开发者,了解了这些后才能和UI更好地沟通和配合.
模型大小约为23M,首次加载模型需要9s左右。(尤其是在做完纹理烘焙后,由于贴图变得复杂,模型更大了) 。
以下是几个优化模型大小的建议:
优先使用.glb而非.gltf格式 。.glb是二进制格式,它比.gltf的JSON格式小25% - 30%左右.
将纹理(Texture)和模型分离,并行加载 。23M的模型中,其实只有2.3M为模型大小,其余都为纹理贴图。将模型和纹理分开后,可以极大减少模型的加载速度.
使用Draco、gltfpack等工具或一些online compressor来压缩模型 (Blender在导出gltf模型时,就带有基于Draco的压缩选项)。本项目通过该步骤压缩了50%的模型大小:3M → 1.2M.
压缩纹理(Texture) 。本项目用到了5张的Texture,压缩后:18M→ 2M.
经过优化,初始模型大小由23M缩小为1.2M,首次加载时间由9s缩短到3s以内.
(左图为优化前,右图为优化后) 。
现在,我们基本完成了整个3D展览馆的开发。虽然有一些细节没有在文中涉及到,但开发过程大致如此.
(1)了解Blender、GLTF / GLB模型 。
(2)js导入GLTF / GLB模型 。
(3)还原设计稿 。
添加光源 。
调整模型材质、增加环境纹理 。
增加阴影 。
(4)实现虚拟移动摇杆,控制镜头移动 。
(5)增加碰撞检测 。
(6)性能调优:
纹理烘培:通过纹理烘焙降低实时光影的性能损耗.
优化包体大小:
- 优先使用.glb而非.gltf格式 。
- 纹理和模型分离 。
- 压缩模型 。
- 压缩纹理 。
一些建议:
设计师在Blender中命名物体、材质时要规范化,避免出现奇怪或没有标识意义的命名,因为在开发过程中会使用到,容易混淆.
设计师在在Blender中复用材质要谨慎,避免开发在调整某个材质时,影响到其它使用到相同材质的物体(潜在bug).
模型加载缓慢时,可以增加loading进度条,缓解等待焦虑。Three.js loader支持加载进度查询.
Three.js在不同版本之间,接口频繁变更,在使用时注意版本差异,google问题时也要注意接口兼容性.
Three.js实现物体发光效果较繁琐,且消耗性能,设计时可尽量避免使用.
Three.js的镜头移动不够丝滑,注重镜头切换流畅性的项目,可以尝试用Babylon.js.
部分浏览器不支持videoTexture(在模型中播放视频),谨慎设计该类型功能,或做好兼容处理.
参考:
Threejs+Blender打造3D全景VR画展「1」 – 码语派教室 。
three js blender animation - Google Search 。
Loading Animated Characters in React Three Fiber 。
浅谈three.js中的needsUpdate - pissang - 博客园 。
Directional Light Shadow - Three.js Tutorials 。
Shadows not working 。
自适应Shadow Bias算法 。
GLB Modify Material and add emission 。
How can I improve the performance of a three.js script?
HTML5 Audio events not triggering on Chrome 。
http://jeromeetienne.github.io/threex.videotexture/examples/videotexture.html 。
部分代码参考自: https://github.com/mayupi/3dvr-gallery 。
最后此篇关于如何用Three.js+Blender打造一个web3D展览馆的文章就讲到这里了,如果你想了解更多关于如何用Three.js+Blender打造一个web3D展览馆的内容请搜索CFSDN的文章或继续浏览相关文章,希望大家以后支持我的博客! 。
我正试图在使用 webvr-boilerplate 的项目中使镜像效果正常工作当 VREffect 处于事件状态时。 我尝试(使用 r72dev 和 r72)使用 THREE.Mirror:
我正在尝试为 localClipping 配置 THREE.plane 的位置,但我发现它有点不直观。我有一个想法,是否可以创建一些planeGeometry,操纵它,并将其位置和方向转换为在其位置创
我有 8 个从 Object3D 到不同方向的光线转换器,用于碰撞检测。我想根据对象旋转旋转他们指向的方向。我遵循了 here 中的解决方案 光线转换器开始旋转,但方式很奇怪。它开始检测来自不同方向的
我在微信上用three.js开发3D游戏,但是有个问题,three.js怎么设置渐变的背景色。我在文档上看到了背景色,但是没有渐变的背景色。请说明我是中国人英语不好。 最佳答案 对于高级效果,请尝试使
我试图通过使用Three.js挤压形状来给SVG路径(lineto和moveto)3D感觉,但是该过程会导致一些我无法删除的瑕疵。 什么会导致渲染的3D形状出现奇异的伪像?有没有办法删除它们? 这些伪
我试图在THREE.js中学习有关性能几何的更多信息,并且已经了解索引BufferGeometry和InstancedBufferGeometry是两种性能最高的几何类型。 到目前为止,我的理解是,在
three.js带有许多有用的控件,这些控件会导致相机响应键盘和鼠标输入而移动。 它们都位于https://github.com/mrdoob/three.js/blob/master/example
几个小时以来,我尝试了数十种不同的方法,但都没有效果,如下所示: document.body.addEventListener("keydown", function() { THREEx.Ful
几个小时以来,我尝试了数十种不同的方法,但都没有效果,如下所示: document.body.addEventListener("keydown", function() { THREEx.Ful
当我创建一个 JSON 模型并使用 THREE.JSONLoader.load 加载它时,它的加载没有任何问题,但是当我尝试从中创建一个变量并将数据包含在主脚本中时,THREE.JSONLoader.
我正在尝试添加一个系统粒子,一个三点到场景,但我有这个错误: “THREE.Object3D.add:对象不是 THREE.Object3D 的实例。未定义” 代码: var backCount =
请参阅以下 fiddle : https://jsfiddle.net/1jmws2bp/ 如果将鼠标移动到线条或圆圈上,它应该将颜色更改为白色(对我来说,本地有效,在 jsfiddle 中有时会有一
我正在努力处理我正在处理的涉及重复图像流的可视化。我让它与一个带有 ParticleSystem 的 Sprite 一起工作,但我只能将一种 Material 应用于系统。因为我想在纹理之间进行选择,
我想移动和旋转摄像头,但要保持PointLight相对于摄像头的位置相同。我读过一堆线程说,您可以将灯光对象添加到摄影机而不是场景。像这样: pointLight = new THREE.PointL
我是 three.js 的新手。我创建了多个网格并使用 JSONLoader 进行了分组。现在我的问题是将动画应用于该组或多个网格。是否可以将动画应用于 JSONLoader 中的该组。 提前致谢。
我想了解如何three.js在内部工作。我不明白的一件事是如何three.js编译并运行着色器程序。如果我要重新实现 three.js我自己我会有一个顶点着色器归因于 Geometry以及归因于 Ma
我以前设法显示了框,但在这里,我已经剥离了所有内容,以便通过定位 collada 模型来试验扩展框,但框不会显示。 function loadObjects(){ cobj = new THREE
我是3D编程的新手,我确实开始使用Three.JS从WebGL探索3D世界。 我想在更改camera.position.z和对象的“Z”位置时预先确定对象的大小。 例如: 我有一个大小为 100x10
在 BufferGeometry 中,有没有一种方法可以访问面索引和法线而不转换为 Geometry? 手头的几何体是由 Threejs 编辑器创建的 SphereBufferGeometry。 我只
我遵循了这个 stackoverflow 示例: ThreeJS geometry flipping 我成功镜像了我的几何体。然而现在我的几何图形是黑色的。我可以在翻转几何体的同时翻转法线来纠正这个问
我是一名优秀的程序员,十分优秀!